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::{generate_xmp_metadata, PdfCompliance, SRGB_ICC_PROFILE};
18use crate::pdf::image::ImageXObject;
19use crate::pdf::security::EncryptionDict;
20
21use gradient::write_gradient_objects;
22use outline::{count_outline_objects, write_outline_objects};
23
24impl PdfDocument {
25    /// Create a new PDF document
26    pub fn new() -> Self {
27        Self {
28            version: "1.4".to_string(),
29            objects: Vec::new(),
30            pages: Vec::new(),
31            info: PdfInfo::default(),
32            image_xobjects: Vec::new(),
33            gradients: Vec::new(),
34            ext_g_states: Vec::new(),
35            outline: None,
36            font_manager: crate::pdf::font::FontManager::new(),
37            encryption: None,
38            file_id: None,
39            compliance: PdfCompliance::Standard,
40        }
41    }
42
43    /// Set the PDF compliance mode
44    ///
45    /// # Errors
46    /// Returns an error if PDF/A compliance is requested together with encryption,
47    /// since PDF/A-1b (ISO 19005-1) forbids encryption.
48    pub fn set_compliance(&mut self, compliance: PdfCompliance) -> Result<()> {
49        if compliance.requires_pdfa() && self.encryption.is_some() {
50            return Err(FopError::Generic(
51                "PDF/A-1b compliance is incompatible with encryption (ISO 19005-1 §6.1.1)"
52                    .to_string(),
53            ));
54        }
55        // PDF/A requires PDF 1.4
56        if compliance.requires_pdfa() {
57            self.version = "1.4".to_string();
58        }
59        self.compliance = compliance;
60        Ok(())
61    }
62
63    /// Add a page to the document
64    pub fn add_page(&mut self, page: PdfPage) {
65        self.pages.push(page);
66    }
67
68    /// Add an image XObject to the document and return its index
69    pub fn add_image_xobject(&mut self, xobject: ImageXObject) -> usize {
70        self.image_xobjects.push(xobject);
71        self.image_xobjects.len() - 1
72    }
73
74    /// Add a gradient shading pattern to the document and return its index
75    ///
76    /// The gradient will be registered as a PDF shading pattern resource.
77    /// Returns the index that can be used to reference this gradient.
78    pub fn add_gradient(&mut self, gradient: Gradient) -> usize {
79        self.gradients.push(PdfGradient {
80            gradient,
81            object_id: 0, // Will be assigned during PDF generation
82        });
83        self.gradients.len() - 1
84    }
85
86    /// Add an ExtGState for opacity/transparency and return its index
87    ///
88    /// Creates an Extended Graphics State dictionary with the specified opacity values.
89    /// Returns the index that can be used to reference this graphics state.
90    ///
91    /// # Arguments
92    /// * `fill_opacity` - Opacity for fill operations (0.0 = transparent, 1.0 = opaque)
93    /// * `stroke_opacity` - Opacity for stroke operations (0.0 = transparent, 1.0 = opaque)
94    pub fn add_ext_g_state(&mut self, fill_opacity: f64, stroke_opacity: f64) -> usize {
95        // Check if this opacity combination already exists
96        for (idx, gs) in self.ext_g_states.iter().enumerate() {
97            if (gs.fill_opacity - fill_opacity).abs() < f64::EPSILON
98                && (gs.stroke_opacity - stroke_opacity).abs() < f64::EPSILON
99            {
100                return idx;
101            }
102        }
103
104        // Add new graphics state
105        self.ext_g_states.push(PdfExtGState {
106            fill_opacity,
107            stroke_opacity,
108            object_id: 0, // Will be assigned during PDF generation
109        });
110        self.ext_g_states.len() - 1
111    }
112
113    /// Set the document outline (bookmarks)
114    pub fn set_outline(&mut self, outline: PdfOutline) {
115        self.outline = Some(outline);
116    }
117
118    /// Set encryption for the document
119    ///
120    /// When encryption is set, `to_bytes()` will encrypt all content streams
121    /// and string objects, and include the /Encrypt dictionary in the trailer.
122    ///
123    /// # Errors
124    /// Returns an error if PDF/A compliance mode is active, since PDF/A-1b
125    /// (ISO 19005-1 §6.1.1) forbids encryption.
126    pub fn set_encryption(&mut self, encryption: EncryptionDict, file_id: Vec<u8>) -> Result<()> {
127        if self.compliance.requires_pdfa() {
128            return Err(FopError::Generic(
129                "PDF/A-1b compliance is incompatible with encryption (ISO 19005-1 §6.1.1)"
130                    .to_string(),
131            ));
132        }
133        self.encryption = Some(encryption);
134        self.file_id = Some(file_id);
135        Ok(())
136    }
137
138    /// Encrypt data for a specific PDF object (if encryption is enabled)
139    fn encrypt_stream(&self, data: &[u8], obj_num: u32) -> Vec<u8> {
140        if let Some(ref enc) = self.encryption {
141            enc.encrypt_data(data, obj_num, 0)
142        } else {
143            data.to_vec()
144        }
145    }
146
147    /// Embed a TrueType font and return its index
148    ///
149    /// # Arguments
150    /// * `font_data` - Raw bytes of the TTF/OTF font file
151    ///
152    /// # Returns
153    /// Font index that can be used with `add_text_with_font`
154    pub fn embed_font(&mut self, font_data: Vec<u8>) -> Result<usize> {
155        self.font_manager.embed_font(font_data)
156    }
157
158    /// Generate PDF bytes
159    pub fn to_bytes(&self) -> Result<Vec<u8>> {
160        let mut bytes = Vec::new();
161        let mut xref_offsets = Vec::new();
162
163        // PDF header
164        bytes.extend_from_slice(format!("%PDF-{}\n", self.version).as_bytes());
165        bytes.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n"); // Binary marker
166
167        // Object 0 is always free
168        xref_offsets.push(0);
169
170        // Calculate outline object count
171        let outline_obj_count = if let Some(ref outline) = self.outline {
172            count_outline_objects(outline)
173        } else {
174            0
175        };
176
177        // Calculate encryption object count (1 if encryption is set)
178        let encrypt_obj_count = if self.encryption.is_some() { 1 } else { 0 };
179
180        // Calculate compliance object IDs / counts
181        // Compliance objects are placed after the encryption dict (if present):
182        //   xmp_obj_id       : XMP metadata stream (if any compliance mode)
183        //   output_intent_id : OutputIntent dict   (if PDF/A)
184        //   icc_profile_id   : ICC profile stream  (if PDF/A)
185        //   struct_tree_id   : StructTreeRoot       (if PDF/UA)
186        let font_obj_id = 3;
187        let first_outline_obj_id = 4;
188        let num_embedded_fonts = self.font_manager.font_count();
189        // Encryption dict goes after outline objects
190        let encrypt_obj_id = first_outline_obj_id + outline_obj_count;
191
192        // Compliance objects follow immediately after the encryption dict slot
193        let compliance_base_id = encrypt_obj_id + encrypt_obj_count;
194        let needs_compliance = self.compliance != PdfCompliance::Standard;
195        let xmp_obj_count = if needs_compliance { 1 } else { 0 };
196        let xmp_obj_id = compliance_base_id; // only valid when needs_compliance
197        let oi_obj_count = if self.compliance.requires_pdfa() {
198            2
199        } else {
200            0
201        };
202        let output_intent_obj_id = compliance_base_id + xmp_obj_count; // only valid when pdfa
203        let icc_profile_obj_id = output_intent_obj_id + 1; // only valid when pdfa
204        let struct_tree_obj_count = if self.compliance.requires_pdfua() {
205            1
206        } else {
207            0
208        };
209        let struct_tree_obj_id = compliance_base_id + xmp_obj_count + oi_obj_count; // only valid when pdfua
210        let total_compliance_obj_count = xmp_obj_count + oi_obj_count + struct_tree_obj_count;
211
212        let first_embedded_font_obj_id = compliance_base_id + total_compliance_obj_count;
213
214        // Object 1: Catalog (root)
215        xref_offsets.push(bytes.len());
216        bytes.extend_from_slice(b"1 0 obj\n");
217        bytes.extend_from_slice(b"<<\n");
218        bytes.extend_from_slice(b"/Type /Catalog\n");
219        bytes.extend_from_slice(b"/Pages 2 0 R\n");
220
221        // Add outline reference if present
222        if self.outline.is_some() {
223            bytes.extend_from_slice(b"/Outlines 4 0 R\n");
224        }
225
226        // PDF/A and PDF/UA catalog entries
227        if needs_compliance {
228            bytes.extend_from_slice(format!("/Metadata {} 0 R\n", xmp_obj_id).as_bytes());
229        }
230
231        if self.compliance.requires_pdfa() {
232            bytes.extend_from_slice(
233                format!("/OutputIntents [{} 0 R]\n", output_intent_obj_id).as_bytes(),
234            );
235        }
236        if self.compliance.requires_pdfua() {
237            bytes.extend_from_slice(b"/MarkInfo <<\n/Marked true\n>>\n");
238            let lang = self.info.lang.as_deref().unwrap_or("en-US");
239            bytes.extend_from_slice(format!("/Lang ({})\n", lang).as_bytes());
240            bytes.extend_from_slice(
241                format!("/StructTreeRoot {} 0 R\n", struct_tree_obj_id).as_bytes(),
242            );
243            bytes.extend_from_slice(b"/ViewerPreferences <<\n/DisplayDocTitle true\n>>\n");
244        } else if let Some(ref lang) = self.info.lang {
245            // Add /Lang to catalog even without PDF/UA when xml:lang is specified
246            bytes.extend_from_slice(format!("/Lang ({})\n", lang).as_bytes());
247        }
248
249        bytes.extend_from_slice(b">>\n");
250        bytes.extend_from_slice(b"endobj\n");
251        let first_image_obj_id = first_embedded_font_obj_id + num_embedded_fonts * 6; // 6 objects per font: descriptor, stream, CIDFont, Type0, ToUnicode, CIDToGIDMap
252        let num_images = self.image_xobjects.len();
253        let first_gradient_obj_id = first_image_obj_id + num_images;
254        let num_gradients = self.gradients.len();
255        let first_ext_g_state_obj_id = first_gradient_obj_id + num_gradients * 2; // 2 objects per gradient
256        let num_ext_g_states = self.ext_g_states.len();
257        let first_page_obj_id = first_ext_g_state_obj_id + num_ext_g_states;
258
259        // Count total annotations and build annotation ranges per page
260        #[allow(unused_variables)]
261        let total_annotations: usize = self.pages.iter().map(|p| p.link_annotations.len()).sum();
262        let first_annotation_obj_id = first_page_obj_id + self.pages.len() * 2;
263
264        // Object 2: Pages (page tree root)
265        xref_offsets.push(bytes.len());
266        bytes.extend_from_slice(b"2 0 obj\n");
267        bytes.extend_from_slice(b"<<\n");
268        bytes.extend_from_slice(b"/Type /Pages\n");
269
270        // Build kids array
271        let page_obj_ids: Vec<usize> = (0..self.pages.len())
272            .map(|i| first_page_obj_id + i * 2)
273            .collect();
274
275        bytes.extend_from_slice(b"/Kids [");
276        for page_id in &page_obj_ids {
277            bytes.extend_from_slice(format!("{} 0 R ", page_id).as_bytes());
278        }
279        bytes.extend_from_slice(b"]\n");
280        bytes.extend_from_slice(format!("/Count {}\n", self.pages.len()).as_bytes());
281        bytes.extend_from_slice(b">>\n");
282        bytes.extend_from_slice(b"endobj\n");
283
284        // Object 3: Font resource (Type 1 Helvetica)
285        xref_offsets.push(bytes.len());
286        bytes.extend_from_slice(format!("{} 0 obj\n", font_obj_id).as_bytes());
287        bytes.extend_from_slice(b"<<\n");
288        bytes.extend_from_slice(b"/Type /Font\n");
289        bytes.extend_from_slice(b"/Subtype /Type1\n");
290        bytes.extend_from_slice(b"/BaseFont /Helvetica\n");
291        bytes.extend_from_slice(b">>\n");
292        bytes.extend_from_slice(b"endobj\n");
293
294        // Generate outline objects if present
295        if let Some(ref outline) = self.outline {
296            write_outline_objects(
297                outline,
298                &mut bytes,
299                &mut xref_offsets,
300                first_outline_obj_id,
301                &page_obj_ids,
302            );
303        }
304
305        // Generate encryption dictionary object if encryption is enabled
306        if let Some(ref enc) = self.encryption {
307            xref_offsets.push(bytes.len());
308            let enc_dict_str = enc.to_pdf_dict(encrypt_obj_id);
309            bytes.extend_from_slice(enc_dict_str.as_bytes());
310        }
311
312        // Generate compliance objects (PDF/A-1b and/or PDF/UA-1)
313        if needs_compliance {
314            // XMP metadata stream (required by both PDF/A and PDF/UA)
315            let title_ref = self.info.title.as_deref();
316            let creator_tool = format!("fop-rs {}", env!("CARGO_PKG_VERSION"));
317            let xmp_content = generate_xmp_metadata(title_ref, &creator_tool, self.compliance);
318            let xmp_bytes = xmp_content.as_bytes();
319            xref_offsets.push(bytes.len());
320            bytes.extend_from_slice(format!("{} 0 obj\n", xmp_obj_id).as_bytes());
321            bytes.extend_from_slice(b"<<\n");
322            bytes.extend_from_slice(b"/Type /Metadata\n");
323            bytes.extend_from_slice(b"/Subtype /XML\n");
324            bytes.extend_from_slice(format!("/Length {}\n", xmp_bytes.len()).as_bytes());
325            bytes.extend_from_slice(b">>\nstream\n");
326            bytes.extend_from_slice(xmp_bytes);
327            bytes.extend_from_slice(b"\nendstream\nendobj\n");
328        }
329
330        if self.compliance.requires_pdfa() {
331            // OutputIntent dictionary referencing the ICC profile stream
332            xref_offsets.push(bytes.len());
333            bytes.extend_from_slice(format!("{} 0 obj\n", output_intent_obj_id).as_bytes());
334            bytes.extend_from_slice(b"<<\n");
335            bytes.extend_from_slice(b"/Type /OutputIntent\n");
336            bytes.extend_from_slice(b"/S /GTS_PDFA1\n");
337            bytes.extend_from_slice(b"/OutputConditionIdentifier (sRGB)\n");
338            bytes.extend_from_slice(b"/RegistryName (http://www.color.org)\n");
339            bytes.extend_from_slice(
340                format!("/DestOutputProfile {} 0 R\n", icc_profile_obj_id).as_bytes(),
341            );
342            bytes.extend_from_slice(b">>\nendobj\n");
343
344            // ICC profile stream (sRGB)
345            let icc_data = SRGB_ICC_PROFILE;
346            xref_offsets.push(bytes.len());
347            bytes.extend_from_slice(format!("{} 0 obj\n", icc_profile_obj_id).as_bytes());
348            bytes.extend_from_slice(b"<<\n");
349            bytes.extend_from_slice(b"/N 3\n"); // 3 colour components for RGB
350            bytes.extend_from_slice(format!("/Length {}\n", icc_data.len()).as_bytes());
351            bytes.extend_from_slice(b">>\nstream\n");
352            bytes.extend_from_slice(icc_data);
353            bytes.extend_from_slice(b"\nendstream\nendobj\n");
354        }
355
356        if self.compliance.requires_pdfua() {
357            // StructTreeRoot — minimal structure tree required by PDF/UA-1
358            xref_offsets.push(bytes.len());
359            bytes.extend_from_slice(format!("{} 0 obj\n", struct_tree_obj_id).as_bytes());
360            bytes.extend_from_slice(b"<<\n");
361            bytes.extend_from_slice(b"/Type /StructTreeRoot\n");
362            bytes.extend_from_slice(b">>\nendobj\n");
363        }
364
365        // Generate embedded font objects (6 objects per font: descriptor, stream, CIDFont, Type0, ToUnicode, CIDToGIDMap)
366        if num_embedded_fonts > 0 {
367            use crate::pdf::font::{
368                generate_cidfont_dict, generate_font_descriptor, generate_font_dictionary,
369                generate_font_stream_header, generate_to_unicode_cmap,
370            };
371
372            let font_objects = self
373                .font_manager
374                .generate_font_objects(first_embedded_font_obj_id)?;
375
376            for (
377                font_idx,
378                (
379                    descriptor_id,
380                    stream_id,
381                    cidfont_id,
382                    type0_dict_id,
383                    to_unicode_id,
384                    cidtogidmap_id,
385                    font,
386                ),
387            ) in font_objects.iter().enumerate()
388            {
389                // Font descriptor object
390                xref_offsets.push(bytes.len());
391                bytes.extend_from_slice(format!("{} 0 obj\n", descriptor_id).as_bytes());
392                bytes.extend_from_slice(generate_font_descriptor(font, *stream_id).as_bytes());
393                bytes.extend_from_slice(b"\nendobj\n");
394
395                // Font stream object (the actual TTF data)
396                xref_offsets.push(bytes.len());
397                bytes.extend_from_slice(format!("{} 0 obj\n", stream_id).as_bytes());
398                bytes.extend_from_slice(generate_font_stream_header(font).as_bytes());
399                bytes.extend_from_slice(b"\nstream\n");
400                bytes.extend_from_slice(&font.font_data);
401                bytes.extend_from_slice(b"\nendstream\n");
402                bytes.extend_from_slice(b"endobj\n");
403
404                // CIDFont dictionary object (CIDFontType2 - TrueType descendant)
405                xref_offsets.push(bytes.len());
406                bytes.extend_from_slice(format!("{} 0 obj\n", cidfont_id).as_bytes());
407                bytes.extend_from_slice(
408                    generate_cidfont_dict(font, *descriptor_id, *cidtogidmap_id).as_bytes(),
409                );
410                bytes.extend_from_slice(b"\nendobj\n");
411
412                // Type 0 font dictionary object (composite font)
413                xref_offsets.push(bytes.len());
414                bytes.extend_from_slice(format!("{} 0 obj\n", type0_dict_id).as_bytes());
415                bytes.extend_from_slice(
416                    generate_font_dictionary(font, *cidfont_id, Some(*to_unicode_id)).as_bytes(),
417                );
418                bytes.extend_from_slice(b"\nendobj\n");
419
420                // ToUnicode CMap object
421                let cmap_content = generate_to_unicode_cmap(font);
422                xref_offsets.push(bytes.len());
423                bytes.extend_from_slice(format!("{} 0 obj\n", to_unicode_id).as_bytes());
424                bytes.extend_from_slice(b"<<\n/Length ");
425                bytes.extend_from_slice(cmap_content.len().to_string().as_bytes());
426                bytes.extend_from_slice(b"\n>>\nstream\n");
427                bytes.extend_from_slice(cmap_content.as_bytes());
428                bytes.extend_from_slice(b"\nendstream\nendobj\n");
429
430                // CIDToGIDMap stream object
431                // Get the subsetter for this font to find used characters
432                let used_chars = if let Some(subsetter) = self.font_manager.get_subsetter(font_idx)
433                {
434                    subsetter.used_chars()
435                } else {
436                    &std::collections::BTreeSet::new()
437                };
438
439                let cidtogidmap_data = crate::pdf::cidfont::generate_cidtogidmap_stream(
440                    &font.char_to_glyph,
441                    used_chars,
442                );
443
444                xref_offsets.push(bytes.len());
445                bytes.extend_from_slice(format!("{} 0 obj\n", cidtogidmap_id).as_bytes());
446                bytes.extend_from_slice(b"<<\n/Length ");
447                bytes.extend_from_slice(cidtogidmap_data.len().to_string().as_bytes());
448                bytes.extend_from_slice(b"\n>>\nstream\n");
449                bytes.extend_from_slice(&cidtogidmap_data);
450                bytes.extend_from_slice(b"\nendstream\nendobj\n");
451            }
452        }
453
454        // Generate image XObject objects
455        for (img_idx, xobject) in self.image_xobjects.iter().enumerate() {
456            let obj_id = first_image_obj_id + img_idx;
457            xref_offsets.push(bytes.len());
458
459            // Write XObject dictionary and stream header
460            let stream_header = xobject.to_pdf_stream(obj_id as u32);
461            bytes.extend_from_slice(stream_header.as_bytes());
462
463            // Write binary stream data
464            bytes.extend_from_slice(xobject.stream_data());
465
466            // Write stream end
467            bytes.extend_from_slice(ImageXObject::stream_end().as_bytes());
468        }
469
470        // Generate gradient shading objects (2 objects per gradient: function + shading)
471        for (grad_idx, pdf_gradient) in self.gradients.iter().enumerate() {
472            let function_obj_id = first_gradient_obj_id + grad_idx * 2;
473            let shading_obj_id = function_obj_id + 1;
474
475            // Generate the gradient objects
476            write_gradient_objects(
477                &pdf_gradient.gradient,
478                function_obj_id,
479                shading_obj_id,
480                &mut bytes,
481                &mut xref_offsets,
482            );
483        }
484
485        // Generate ExtGState objects for transparency
486        for (gs_idx, ext_g_state) in self.ext_g_states.iter().enumerate() {
487            let obj_id = first_ext_g_state_obj_id + gs_idx;
488            xref_offsets.push(bytes.len());
489            bytes.extend_from_slice(format!("{} 0 obj\n", obj_id).as_bytes());
490            bytes.extend_from_slice(b"<<\n");
491            bytes.extend_from_slice(b"/Type /ExtGState\n");
492            bytes.extend_from_slice(format!("/ca {:.3}\n", ext_g_state.fill_opacity).as_bytes());
493            bytes.extend_from_slice(format!("/CA {:.3}\n", ext_g_state.stroke_opacity).as_bytes());
494            bytes.extend_from_slice(b">>\n");
495            bytes.extend_from_slice(b"endobj\n");
496        }
497
498        // Generate page objects and content streams in order
499        let mut current_annotation_obj_id = first_annotation_obj_id;
500        for (page_idx, page) in self.pages.iter().enumerate() {
501            let page_obj_id = first_page_obj_id + page_idx * 2;
502            let content_obj_id = page_obj_id + 1;
503
504            // Page object first
505            xref_offsets.push(bytes.len());
506            bytes.extend_from_slice(format!("{} 0 obj\n", page_obj_id).as_bytes());
507            bytes.extend_from_slice(b"<<\n");
508            bytes.extend_from_slice(b"/Type /Page\n");
509            bytes.extend_from_slice(b"/Parent 2 0 R\n");
510            bytes.extend_from_slice(
511                format!(
512                    "/MediaBox [0 0 {} {}]\n",
513                    page.width.to_pt(),
514                    page.height.to_pt()
515                )
516                .as_bytes(),
517            );
518            bytes.extend_from_slice(b"/Resources <<\n");
519
520            // Font resources: F1 is Helvetica, F2+ are embedded fonts
521            bytes.extend_from_slice(b"  /Font <<\n");
522            bytes.extend_from_slice(format!("    /F1 {} 0 R\n", font_obj_id).as_bytes());
523
524            // Add embedded fonts as F2, F3, F4, etc.
525            if num_embedded_fonts > 0 {
526                for font_idx in 0..num_embedded_fonts {
527                    let type0_dict_obj_id = first_embedded_font_obj_id + font_idx * 6 + 3; // Type0 dictionary is 4th object (6 objects per font)
528                    bytes.extend_from_slice(
529                        format!("    /F{} {} 0 R\n", font_idx + 2, type0_dict_obj_id).as_bytes(),
530                    );
531                }
532            }
533            bytes.extend_from_slice(b"  >>\n");
534
535            // Add XObject resources if there are any images
536            if !self.image_xobjects.is_empty() {
537                bytes.extend_from_slice(b"  /XObject <<\n");
538                for img_idx in 0..self.image_xobjects.len() {
539                    let obj_id = first_image_obj_id + img_idx;
540                    bytes.extend_from_slice(
541                        format!("    /Im{} {} 0 R\n", img_idx, obj_id).as_bytes(),
542                    );
543                }
544                bytes.extend_from_slice(b"  >>\n");
545            }
546
547            // Add Shading resources if there are any gradients
548            if !self.gradients.is_empty() {
549                bytes.extend_from_slice(b"  /Shading <<\n");
550                for grad_idx in 0..self.gradients.len() {
551                    let shading_obj_id = first_gradient_obj_id + grad_idx * 2 + 1; // Shading is 2nd object
552                    bytes.extend_from_slice(
553                        format!("    /Sh{} {} 0 R\n", grad_idx, shading_obj_id).as_bytes(),
554                    );
555                }
556                bytes.extend_from_slice(b"  >>\n");
557            }
558
559            // Add ExtGState resources if there are any transparency settings
560            if !self.ext_g_states.is_empty() {
561                bytes.extend_from_slice(b"  /ExtGState <<\n");
562                for gs_idx in 0..self.ext_g_states.len() {
563                    let gs_obj_id = first_ext_g_state_obj_id + gs_idx;
564                    bytes.extend_from_slice(
565                        format!("    /GS{} {} 0 R\n", gs_idx, gs_obj_id).as_bytes(),
566                    );
567                }
568                bytes.extend_from_slice(b"  >>\n");
569            }
570
571            bytes.extend_from_slice(b">>\n");
572            bytes.extend_from_slice(format!("/Contents {} 0 R\n", content_obj_id).as_bytes());
573
574            // Add /Annots array if this page has link annotations
575            if !page.link_annotations.is_empty() {
576                bytes.extend_from_slice(b"/Annots [");
577                for annot_idx in 0..page.link_annotations.len() {
578                    bytes.extend_from_slice(
579                        format!("{} 0 R ", current_annotation_obj_id + annot_idx).as_bytes(),
580                    );
581                }
582                bytes.extend_from_slice(b"]\n");
583                current_annotation_obj_id += page.link_annotations.len();
584            }
585
586            bytes.extend_from_slice(b">>\n");
587            bytes.extend_from_slice(b"endobj\n");
588
589            // Content stream object second (encrypt if needed)
590            let stream_data = self.encrypt_stream(&page.content, content_obj_id as u32);
591            xref_offsets.push(bytes.len());
592            bytes.extend_from_slice(format!("{} 0 obj\n", content_obj_id).as_bytes());
593            bytes.extend_from_slice(b"<<\n");
594            bytes.extend_from_slice(format!("/Length {}\n", stream_data.len()).as_bytes());
595            bytes.extend_from_slice(b">>\n");
596            bytes.extend_from_slice(b"stream\n");
597            bytes.extend_from_slice(&stream_data);
598            bytes.extend_from_slice(b"\nendstream\n");
599            bytes.extend_from_slice(b"endobj\n");
600        }
601
602        // Generate link annotation objects
603        if total_annotations > 0 {
604            let mut annot_obj_id = first_annotation_obj_id;
605            for (page_idx, page) in self.pages.iter().enumerate() {
606                let page_obj_id = first_page_obj_id + page_idx * 2;
607
608                for annot in &page.link_annotations {
609                    xref_offsets.push(bytes.len());
610                    bytes.extend_from_slice(format!("{} 0 obj\n", annot_obj_id).as_bytes());
611                    bytes.extend_from_slice(b"<<\n");
612                    bytes.extend_from_slice(b"/Type /Annot\n");
613                    bytes.extend_from_slice(b"/Subtype /Link\n");
614                    bytes.extend_from_slice(
615                        format!(
616                            "/Rect [{:.2} {:.2} {:.2} {:.2}]\n",
617                            annot.rect[0], annot.rect[1], annot.rect[2], annot.rect[3]
618                        )
619                        .as_bytes(),
620                    );
621                    bytes.extend_from_slice(format!("/P {} 0 R\n", page_obj_id).as_bytes());
622                    bytes.extend_from_slice(b"/Border [0 0 0]\n"); // No border
623
624                    // Add destination based on type
625                    match &annot.destination {
626                        LinkDestination::External(url) => {
627                            bytes.extend_from_slice(b"/A <<\n");
628                            bytes.extend_from_slice(b"  /S /URI\n");
629                            bytes.extend_from_slice(
630                                format!("  /URI ({})\n", outline::escape_pdf_string(url))
631                                    .as_bytes(),
632                            );
633                            bytes.extend_from_slice(b">>\n");
634                        }
635                        LinkDestination::Internal(dest_id) => {
636                            // For internal destinations, we would need to resolve the ID to a page
637                            // For now, use a named destination
638                            bytes.extend_from_slice(
639                                format!("/Dest ({})\n", outline::escape_pdf_string(dest_id))
640                                    .as_bytes(),
641                            );
642                        }
643                    }
644
645                    bytes.extend_from_slice(b">>\n");
646                    bytes.extend_from_slice(b"endobj\n");
647
648                    annot_obj_id += 1;
649                }
650            }
651        }
652
653        // Cross-reference table
654        let xref_offset = bytes.len();
655        bytes.extend_from_slice(b"xref\n");
656        bytes.extend_from_slice(format!("0 {}\n", xref_offsets.len()).as_bytes());
657        bytes.extend_from_slice(b"0000000000 65535 f \n"); // Object 0 is free
658        for offset in xref_offsets.iter().skip(1) {
659            bytes.extend_from_slice(format!("{:010} 00000 n \n", offset).as_bytes());
660        }
661
662        // Trailer
663        bytes.extend_from_slice(b"trailer\n");
664        bytes.extend_from_slice(b"<<\n");
665        bytes.extend_from_slice(format!("/Size {}\n", xref_offsets.len()).as_bytes());
666        bytes.extend_from_slice(b"/Root 1 0 R\n");
667
668        // Add /Encrypt reference if encryption is enabled
669        if self.encryption.is_some() {
670            bytes.extend_from_slice(format!("/Encrypt {} 0 R\n", encrypt_obj_id).as_bytes());
671        }
672
673        // Add /ID array (required for encryption, recommended for all PDFs)
674        if let Some(ref file_id) = self.file_id {
675            let hex = file_id
676                .iter()
677                .map(|b| format!("{:02X}", b))
678                .collect::<String>();
679            bytes.extend_from_slice(format!("/ID [<{}> <{}>]\n", hex, hex).as_bytes());
680        }
681
682        // Add document info if any metadata is present
683        if self.info.title.is_some()
684            || self.info.author.is_some()
685            || self.info.subject.is_some()
686            || self.info.creation_date.is_some()
687        {
688            bytes.extend_from_slice(b"/Info <<\n");
689
690            if let Some(ref title) = self.info.title {
691                bytes.extend_from_slice(format!("  /Title ({})\n", title).as_bytes());
692            }
693
694            if let Some(ref author) = self.info.author {
695                bytes.extend_from_slice(format!("  /Author ({})\n", author).as_bytes());
696            }
697
698            if let Some(ref subject) = self.info.subject {
699                bytes.extend_from_slice(format!("  /Subject ({})\n", subject).as_bytes());
700            }
701
702            if let Some(ref creation_date) = self.info.creation_date {
703                bytes
704                    .extend_from_slice(format!("  /CreationDate ({})\n", creation_date).as_bytes());
705            }
706
707            bytes.extend_from_slice(b">>\n");
708        }
709
710        bytes.extend_from_slice(b">>\n");
711        bytes.extend_from_slice(b"startxref\n");
712        bytes.extend_from_slice(format!("{}\n", xref_offset).as_bytes());
713        bytes.extend_from_slice(b"%%EOF\n");
714
715        Ok(bytes)
716    }
717}
718
719impl Default for PdfDocument {
720    fn default() -> Self {
721        Self::new()
722    }
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728
729    #[test]
730    fn test_pdf_document_creation() {
731        let doc = PdfDocument::new();
732        assert_eq!(doc.version, "1.4");
733        assert_eq!(doc.pages.len(), 0);
734    }
735
736    #[test]
737    fn test_pdf_page() {
738        let mut page = PdfPage::new(
739            fop_types::Length::from_mm(210.0),
740            fop_types::Length::from_mm(297.0),
741        );
742
743        page.add_text(
744            "Hello World",
745            fop_types::Length::from_pt(100.0),
746            fop_types::Length::from_pt(700.0),
747            fop_types::Length::from_pt(12.0),
748        );
749
750        assert!(!page.content.is_empty());
751        let content_str = String::from_utf8_lossy(&page.content);
752        assert!(content_str.contains("Hello World"));
753        assert!(content_str.contains("BT")); // Begin text
754        assert!(content_str.contains("ET")); // End text
755    }
756
757    #[test]
758    fn test_pdf_bytes() {
759        let doc = PdfDocument::new();
760        let bytes = doc.to_bytes().expect("test: should succeed");
761
762        let header = String::from_utf8_lossy(&bytes[..8]);
763        assert!(header.starts_with("%PDF-"));
764    }
765
766    #[test]
767    fn test_pdf_encrypted_bytes() {
768        use crate::pdf::security::{generate_file_id, PdfPermissions, PdfSecurity};
769
770        let mut doc = PdfDocument::new();
771
772        // Add a page with content
773        let mut page = PdfPage::new(
774            fop_types::Length::from_mm(210.0),
775            fop_types::Length::from_mm(297.0),
776        );
777        page.add_text(
778            "Secret Text",
779            fop_types::Length::from_pt(100.0),
780            fop_types::Length::from_pt(700.0),
781            fop_types::Length::from_pt(12.0),
782        );
783        doc.add_page(page);
784
785        // Set encryption
786        let permissions = PdfPermissions {
787            allow_print: false,
788            allow_copy: false,
789            ..Default::default()
790        };
791        let security = PdfSecurity::new("owner123", "user456", permissions);
792        let file_id = generate_file_id("test-encrypted");
793        let encryption_dict = security.compute_encryption_dict(&file_id);
794        doc.set_encryption(encryption_dict, file_id)
795            .expect("test: should succeed");
796
797        let bytes = doc.to_bytes().expect("test: should succeed");
798        let content = String::from_utf8_lossy(&bytes);
799
800        // Verify encrypted PDF structure
801        assert!(content.contains("%PDF-"));
802        assert!(content.contains("/Filter /Standard"));
803        assert!(content.contains("/V 2")); // Version 2 (RC4-128)
804        assert!(content.contains("/R 3")); // Revision 3
805        assert!(content.contains("/Length 128"));
806        assert!(content.contains("/Encrypt")); // Trailer has /Encrypt
807        assert!(content.contains("/ID [<")); // Trailer has /ID
808
809        // Content stream should be encrypted (not contain plaintext)
810        assert!(!content.contains("Secret Text"));
811    }
812
813    #[test]
814    fn test_pdf_without_encryption_has_plaintext() {
815        let mut doc = PdfDocument::new();
816        let mut page = PdfPage::new(
817            fop_types::Length::from_mm(210.0),
818            fop_types::Length::from_mm(297.0),
819        );
820        page.add_text(
821            "Visible Text",
822            fop_types::Length::from_pt(100.0),
823            fop_types::Length::from_pt(700.0),
824            fop_types::Length::from_pt(12.0),
825        );
826        doc.add_page(page);
827
828        let bytes = doc.to_bytes().expect("test: should succeed");
829        let content = String::from_utf8_lossy(&bytes);
830
831        // Without encryption, text should be visible in the PDF
832        assert!(content.contains("Visible Text"));
833        // Should NOT have encryption entries
834        assert!(!content.contains("/Encrypt"));
835        assert!(!content.contains("/Filter /Standard"));
836    }
837}
838
839#[cfg(test)]
840mod tests_extended {
841    use super::*;
842    use crate::pdf::compliance::PdfCompliance;
843    use crate::pdf::security::{generate_file_id, PdfPermissions, PdfSecurity};
844    use fop_types::Length;
845
846    #[test]
847    fn test_pdf_document_default() {
848        let doc = PdfDocument::default();
849        assert_eq!(doc.version, "1.4");
850        assert!(doc.pages.is_empty());
851    }
852
853    #[test]
854    fn test_pdf_document_add_multiple_pages() {
855        let mut doc = PdfDocument::new();
856        for _ in 0..3 {
857            let page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
858            doc.add_page(page);
859        }
860        assert_eq!(doc.pages.len(), 3);
861    }
862
863    #[test]
864    fn test_pdf_page_new_has_correct_dimensions() {
865        let page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
866        assert_eq!(page.width, Length::from_mm(210.0));
867        assert_eq!(page.height, Length::from_mm(297.0));
868        assert!(page.content.is_empty());
869    }
870
871    #[test]
872    fn test_pdf_page_add_text_generates_bt_et() {
873        let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
874        page.add_text(
875            "Test",
876            Length::from_pt(72.0),
877            Length::from_pt(700.0),
878            Length::from_pt(12.0),
879        );
880        let content = String::from_utf8_lossy(&page.content);
881        assert!(content.contains("BT"));
882        assert!(content.contains("ET"));
883    }
884
885    #[test]
886    fn test_pdf_page_add_background() {
887        let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
888        page.add_background(
889            Length::ZERO,
890            Length::ZERO,
891            Length::from_pt(595.0),
892            Length::from_pt(842.0),
893            fop_types::Color::WHITE,
894        );
895        let content = String::from_utf8_lossy(&page.content);
896        // Should have filled rectangle
897        assert!(content.contains("re f"));
898    }
899
900    #[test]
901    fn test_pdf_compliance_pdfa1b_adds_version_info() {
902        let mut doc = PdfDocument::new();
903        doc.set_compliance(PdfCompliance::PdfA1b)
904            .expect("test: should succeed");
905        let bytes = doc.to_bytes().expect("test: should succeed");
906        let content = String::from_utf8_lossy(&bytes);
907        // PDF/A uses PDF 1.4
908        assert!(content.contains("%PDF-1.4"));
909    }
910
911    #[test]
912    fn test_pdf_document_to_bytes_starts_with_header() {
913        let doc = PdfDocument::new();
914        let bytes = doc.to_bytes().expect("test: should succeed");
915        assert!(bytes.starts_with(b"%PDF-"));
916    }
917
918    #[test]
919    fn test_pdf_document_to_bytes_ends_with_eof() {
920        let doc = PdfDocument::new();
921        let bytes = doc.to_bytes().expect("test: should succeed");
922        let content = String::from_utf8_lossy(&bytes);
923        assert!(content.contains("%%EOF"));
924    }
925
926    #[test]
927    fn test_pdf_document_aes256_encryption() {
928        let mut doc = PdfDocument::new();
929        let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
930        page.add_text(
931            "Private",
932            Length::from_pt(72.0),
933            Length::from_pt(700.0),
934            Length::from_pt(12.0),
935        );
936        doc.add_page(page);
937
938        let sec = PdfSecurity::new_aes256("owner", "user", PdfPermissions::default());
939        let file_id = generate_file_id("aes-doc");
940        let dict = sec.compute_encryption_dict(&file_id);
941        doc.set_encryption(dict, file_id)
942            .expect("test: should succeed");
943
944        let bytes = doc.to_bytes().expect("test: should succeed");
945        let content = String::from_utf8_lossy(&bytes);
946        assert!(content.contains("/V 5")); // AES-256 version
947        assert!(content.contains("/R 6")); // Revision 6
948        assert!(content.contains("/OE <")); // owner encrypted key
949    }
950
951    #[test]
952    fn test_pdf_outline_structure() {
953        let mut doc = PdfDocument::new();
954        let outline = PdfOutline {
955            items: vec![
956                PdfOutlineItem {
957                    title: "Chapter 1".to_string(),
958                    page_index: Some(0),
959                    external_destination: None,
960                    children: vec![],
961                },
962                PdfOutlineItem {
963                    title: "Chapter 2".to_string(),
964                    page_index: Some(1),
965                    external_destination: None,
966                    children: vec![],
967                },
968            ],
969        };
970        doc.set_outline(outline);
971
972        // Add pages for the outline to reference
973        for _ in 0..2 {
974            let page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
975            doc.add_page(page);
976        }
977
978        let bytes = doc.to_bytes().expect("test: should succeed");
979        let content = String::from_utf8_lossy(&bytes);
980        assert!(content.contains("Chapter 1"));
981        assert!(content.contains("Chapter 2"));
982        assert!(content.contains("/Outlines"));
983    }
984
985    #[test]
986    fn test_pdf_page_add_rule() {
987        let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
988        page.add_rule(
989            Length::from_pt(50.0),
990            Length::from_pt(400.0),
991            Length::from_pt(400.0),
992            Length::from_pt(2.0),
993            fop_types::Color::BLACK,
994            "solid",
995        );
996        let content = String::from_utf8_lossy(&page.content);
997        // Should have line drawing content
998        assert!(!content.is_empty());
999    }
1000}
1001
1002#[cfg(test)]
1003mod tests_document_comprehensive {
1004    use super::*;
1005    use fop_types::Length;
1006
1007    // ── PdfDocument::new() ────────────────────────────────────────────────────
1008
1009    #[test]
1010    fn test_new_produces_non_empty_output() {
1011        let doc = PdfDocument::new();
1012        let bytes = doc.to_bytes().expect("test: should succeed");
1013        assert!(!bytes.is_empty());
1014    }
1015
1016    #[test]
1017    fn test_new_version_is_1_4() {
1018        let doc = PdfDocument::new();
1019        assert_eq!(doc.version, "1.4");
1020    }
1021
1022    #[test]
1023    fn test_new_has_no_pages() {
1024        let doc = PdfDocument::new();
1025        assert_eq!(doc.pages.len(), 0);
1026    }
1027
1028    #[test]
1029    fn test_new_has_no_images() {
1030        let doc = PdfDocument::new();
1031        assert_eq!(doc.image_xobjects.len(), 0);
1032    }
1033
1034    #[test]
1035    fn test_new_has_no_outline() {
1036        let doc = PdfDocument::new();
1037        assert!(doc.outline.is_none());
1038    }
1039
1040    // ── PDF version header ────────────────────────────────────────────────────
1041
1042    #[test]
1043    fn test_pdf_header_starts_with_pdf_1_4() {
1044        let doc = PdfDocument::new();
1045        let bytes = doc.to_bytes().expect("test: should succeed");
1046        assert!(bytes.starts_with(b"%PDF-1.4"));
1047    }
1048
1049    #[test]
1050    fn test_pdf_header_present_in_output() {
1051        let doc = PdfDocument::new();
1052        let bytes = doc.to_bytes().expect("test: should succeed");
1053        let s = String::from_utf8_lossy(&bytes);
1054        assert!(s.contains("%PDF-"));
1055    }
1056
1057    // ── Page count in catalog ─────────────────────────────────────────────────
1058
1059    #[test]
1060    fn test_page_count_zero_pages_in_catalog() {
1061        let doc = PdfDocument::new();
1062        let bytes = doc.to_bytes().expect("test: should succeed");
1063        let s = String::from_utf8_lossy(&bytes);
1064        assert!(s.contains("/Count 0"));
1065    }
1066
1067    #[test]
1068    fn test_page_count_one_page_in_catalog() {
1069        let mut doc = PdfDocument::new();
1070        doc.add_page(PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0)));
1071        let bytes = doc.to_bytes().expect("test: should succeed");
1072        let s = String::from_utf8_lossy(&bytes);
1073        assert!(s.contains("/Count 1"));
1074    }
1075
1076    #[test]
1077    fn test_page_count_three_pages_in_catalog() {
1078        let mut doc = PdfDocument::new();
1079        for _ in 0..3 {
1080            doc.add_page(PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0)));
1081        }
1082        let bytes = doc.to_bytes().expect("test: should succeed");
1083        let s = String::from_utf8_lossy(&bytes);
1084        assert!(s.contains("/Count 3"));
1085        assert_eq!(doc.pages.len(), 3);
1086    }
1087
1088    // ── Info dictionary fields ────────────────────────────────────────────────
1089
1090    #[test]
1091    fn test_info_title_appears_in_output() {
1092        let mut doc = PdfDocument::new();
1093        doc.info.title = Some("My Test Document".to_string());
1094        let bytes = doc.to_bytes().expect("test: should succeed");
1095        let s = String::from_utf8_lossy(&bytes);
1096        assert!(s.contains("/Title (My Test Document)"));
1097    }
1098
1099    #[test]
1100    fn test_info_author_appears_in_output() {
1101        let mut doc = PdfDocument::new();
1102        doc.info.author = Some("Jane Doe".to_string());
1103        let bytes = doc.to_bytes().expect("test: should succeed");
1104        let s = String::from_utf8_lossy(&bytes);
1105        assert!(s.contains("/Author (Jane Doe)"));
1106    }
1107
1108    #[test]
1109    fn test_info_subject_appears_in_output() {
1110        let mut doc = PdfDocument::new();
1111        doc.info.subject = Some("Unit Testing".to_string());
1112        let bytes = doc.to_bytes().expect("test: should succeed");
1113        let s = String::from_utf8_lossy(&bytes);
1114        assert!(s.contains("/Subject (Unit Testing)"));
1115    }
1116
1117    #[test]
1118    fn test_info_creation_date_appears_in_output() {
1119        let mut doc = PdfDocument::new();
1120        doc.info.creation_date = Some("D:20260220120000".to_string());
1121        let bytes = doc.to_bytes().expect("test: should succeed");
1122        let s = String::from_utf8_lossy(&bytes);
1123        assert!(s.contains("/CreationDate (D:20260220120000)"));
1124    }
1125
1126    #[test]
1127    fn test_info_lang_field_roundtrip() {
1128        let mut info = PdfInfo::default();
1129        assert!(info.lang.is_none());
1130        info.lang = Some("ja".to_string());
1131        assert_eq!(info.lang.as_deref(), Some("ja"));
1132    }
1133
1134    #[test]
1135    fn test_info_no_metadata_omits_info_dict() {
1136        // A fresh document with no metadata should have no /Info entry
1137        let doc = PdfDocument::new();
1138        let bytes = doc.to_bytes().expect("test: should succeed");
1139        let s = String::from_utf8_lossy(&bytes);
1140        assert!(!s.contains("/Info <<"));
1141    }
1142
1143    #[test]
1144    fn test_info_all_fields_set() {
1145        let mut doc = PdfDocument::new();
1146        doc.info.title = Some("Full Meta".to_string());
1147        doc.info.author = Some("Author A".to_string());
1148        doc.info.subject = Some("Subject S".to_string());
1149        doc.info.creation_date = Some("D:20260101".to_string());
1150        let bytes = doc.to_bytes().expect("test: should succeed");
1151        let s = String::from_utf8_lossy(&bytes);
1152        assert!(s.contains("/Title (Full Meta)"));
1153        assert!(s.contains("/Author (Author A)"));
1154        assert!(s.contains("/Subject (Subject S)"));
1155        assert!(s.contains("/CreationDate (D:20260101)"));
1156    }
1157
1158    // ── Cross-reference table structure ───────────────────────────────────────
1159
1160    #[test]
1161    fn test_xref_table_present() {
1162        let doc = PdfDocument::new();
1163        let bytes = doc.to_bytes().expect("test: should succeed");
1164        let s = String::from_utf8_lossy(&bytes);
1165        assert!(s.contains("xref\n"));
1166    }
1167
1168    #[test]
1169    fn test_xref_free_object_zero() {
1170        let doc = PdfDocument::new();
1171        let bytes = doc.to_bytes().expect("test: should succeed");
1172        let s = String::from_utf8_lossy(&bytes);
1173        // Object 0 must be the free-object entry
1174        assert!(s.contains("0000000000 65535 f "));
1175    }
1176
1177    #[test]
1178    fn test_xref_entries_use_n_type() {
1179        let doc = PdfDocument::new();
1180        let bytes = doc.to_bytes().expect("test: should succeed");
1181        let s = String::from_utf8_lossy(&bytes);
1182        // At least one in-use entry must be present
1183        assert!(s.contains(" 00000 n "));
1184    }
1185
1186    // ── Trailer dictionary ────────────────────────────────────────────────────
1187
1188    #[test]
1189    fn test_trailer_has_root_reference() {
1190        let doc = PdfDocument::new();
1191        let bytes = doc.to_bytes().expect("test: should succeed");
1192        let s = String::from_utf8_lossy(&bytes);
1193        assert!(s.contains("/Root 1 0 R"));
1194    }
1195
1196    #[test]
1197    fn test_trailer_has_size_entry() {
1198        let doc = PdfDocument::new();
1199        let bytes = doc.to_bytes().expect("test: should succeed");
1200        let s = String::from_utf8_lossy(&bytes);
1201        // Trailer must contain a /Size entry
1202        assert!(s.contains("/Size "));
1203    }
1204
1205    // ── startxref offset ─────────────────────────────────────────────────────
1206
1207    #[test]
1208    fn test_startxref_keyword_present() {
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("startxref\n"));
1213    }
1214
1215    #[test]
1216    fn test_startxref_offset_is_nonzero() {
1217        let doc = PdfDocument::new();
1218        let bytes = doc.to_bytes().expect("test: should succeed");
1219        let s = String::from_utf8_lossy(&bytes);
1220        // Find startxref and read the number after it
1221        let idx = s.find("startxref\n").expect("test: should succeed");
1222        let after = &s[idx + "startxref\n".len()..];
1223        let offset_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
1224        let offset: usize = offset_str.parse().expect("test: should succeed");
1225        assert!(offset > 0);
1226    }
1227
1228    #[test]
1229    fn test_eof_marker_present() {
1230        let doc = PdfDocument::new();
1231        let bytes = doc.to_bytes().expect("test: should succeed");
1232        let s = String::from_utf8_lossy(&bytes);
1233        assert!(s.contains("%%EOF"));
1234    }
1235
1236    // ── PdfInfo struct ────────────────────────────────────────────────────────
1237
1238    #[test]
1239    fn test_pdfinfo_default_all_none() {
1240        let info = PdfInfo::default();
1241        assert!(info.title.is_none());
1242        assert!(info.author.is_none());
1243        assert!(info.subject.is_none());
1244        assert!(info.creation_date.is_none());
1245        assert!(info.lang.is_none());
1246    }
1247
1248    #[test]
1249    fn test_pdfinfo_clone() {
1250        let info = PdfInfo {
1251            title: Some("Clone Me".to_string()),
1252            ..Default::default()
1253        };
1254        let cloned = info.clone();
1255        assert_eq!(cloned.title.as_deref(), Some("Clone Me"));
1256    }
1257
1258    // ── Document ID in trailer ────────────────────────────────────────────────
1259
1260    #[test]
1261    fn test_file_id_appears_in_trailer_as_id_array() {
1262        use crate::pdf::security::generate_file_id;
1263        let mut doc = PdfDocument::new();
1264        let fid = generate_file_id("id-test");
1265        // Set file_id directly (without encryption)
1266        doc.file_id = Some(fid);
1267        let bytes = doc.to_bytes().expect("test: should succeed");
1268        let s = String::from_utf8_lossy(&bytes);
1269        assert!(s.contains("/ID [<"));
1270    }
1271
1272    // ── add_ext_g_state deduplication ─────────────────────────────────────────
1273
1274    #[test]
1275    fn test_add_ext_g_state_deduplication() {
1276        let mut doc = PdfDocument::new();
1277        let idx1 = doc.add_ext_g_state(0.5, 0.5);
1278        let idx2 = doc.add_ext_g_state(0.5, 0.5);
1279        assert_eq!(idx1, idx2);
1280        assert_eq!(doc.ext_g_states.len(), 1);
1281    }
1282
1283    #[test]
1284    fn test_add_ext_g_state_different_values_creates_two() {
1285        let mut doc = PdfDocument::new();
1286        let idx1 = doc.add_ext_g_state(0.3, 0.3);
1287        let idx2 = doc.add_ext_g_state(0.7, 0.7);
1288        assert_ne!(idx1, idx2);
1289        assert_eq!(doc.ext_g_states.len(), 2);
1290    }
1291
1292    // ── add_gradient ─────────────────────────────────────────────────────────
1293
1294    #[test]
1295    fn test_add_gradient_returns_index() {
1296        use fop_types::{Color, ColorStop, Gradient, Length, Point};
1297        let mut doc = PdfDocument::new();
1298        let gradient = Gradient::linear(
1299            Point::new(Length::from_pt(0.0), Length::from_pt(0.0)),
1300            Point::new(Length::from_pt(100.0), Length::from_pt(0.0)),
1301            vec![
1302                ColorStop::new(0.0, Color::BLACK),
1303                ColorStop::new(1.0, Color::WHITE),
1304            ],
1305        );
1306        let idx = doc.add_gradient(gradient);
1307        assert_eq!(idx, 0);
1308        assert_eq!(doc.gradients.len(), 1);
1309    }
1310
1311    // ── Catalog structure ─────────────────────────────────────────────────────
1312
1313    #[test]
1314    fn test_catalog_type_present() {
1315        let doc = PdfDocument::new();
1316        let bytes = doc.to_bytes().expect("test: should succeed");
1317        let s = String::from_utf8_lossy(&bytes);
1318        assert!(s.contains("/Type /Catalog"));
1319    }
1320
1321    #[test]
1322    fn test_catalog_pages_reference_present() {
1323        let doc = PdfDocument::new();
1324        let bytes = doc.to_bytes().expect("test: should succeed");
1325        let s = String::from_utf8_lossy(&bytes);
1326        assert!(s.contains("/Pages 2 0 R"));
1327    }
1328
1329    // ── PdfPage ───────────────────────────────────────────────────────────────
1330
1331    #[test]
1332    fn test_pdfpage_new_empty_content() {
1333        let page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
1334        assert!(page.content.is_empty());
1335        assert!(page.link_annotations.is_empty());
1336    }
1337
1338    #[test]
1339    fn test_pdfpage_add_text_with_spacing_produces_tc_tw() {
1340        let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
1341        page.add_text_with_spacing(
1342            "Hello",
1343            Length::from_pt(72.0),
1344            Length::from_pt(700.0),
1345            Length::from_pt(12.0),
1346            Some(Length::from_pt(1.0)),
1347            Some(Length::from_pt(2.0)),
1348        );
1349        let content = String::from_utf8_lossy(&page.content);
1350        assert!(content.contains("Tc"));
1351        assert!(content.contains("Tw"));
1352    }
1353
1354    #[test]
1355    fn test_pdfpage_add_background_generates_rg_and_re() {
1356        let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
1357        page.add_background(
1358            Length::from_pt(10.0),
1359            Length::from_pt(10.0),
1360            Length::from_pt(200.0),
1361            Length::from_pt(100.0),
1362            fop_types::Color::rgb(255, 0, 0),
1363        );
1364        let content = String::from_utf8_lossy(&page.content);
1365        // Color set (rg) and rectangle drawn (re f)
1366        assert!(content.contains("rg"));
1367        assert!(content.contains("re f"));
1368    }
1369
1370    #[test]
1371    fn test_pdfpage_add_link_annotation_stores_annotation() {
1372        let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
1373        page.add_link_annotation(
1374            Length::from_pt(50.0),
1375            Length::from_pt(700.0),
1376            Length::from_pt(100.0),
1377            Length::from_pt(12.0),
1378            LinkDestination::External("https://example.com".to_string()),
1379        );
1380        assert_eq!(page.link_annotations.len(), 1);
1381    }
1382
1383    #[test]
1384    fn test_pdfpage_link_annotation_rect_values() {
1385        let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
1386        page.add_link_annotation(
1387            Length::from_pt(10.0),
1388            Length::from_pt(20.0),
1389            Length::from_pt(80.0),
1390            Length::from_pt(14.0),
1391            LinkDestination::Internal("section-1".to_string()),
1392        );
1393        let ann = &page.link_annotations[0];
1394        // rect: [x, y, x+w, y+h]
1395        assert!((ann.rect[0] - 10.0).abs() < 0.01);
1396        assert!((ann.rect[1] - 20.0).abs() < 0.01);
1397        assert!((ann.rect[2] - 90.0).abs() < 0.01);
1398        assert!((ann.rect[3] - 34.0).abs() < 0.01);
1399    }
1400
1401    #[test]
1402    fn test_pdfpage_multiple_texts_accumulate_in_content() {
1403        let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
1404        page.add_text(
1405            "First",
1406            Length::from_pt(72.0),
1407            Length::from_pt(700.0),
1408            Length::from_pt(12.0),
1409        );
1410        page.add_text(
1411            "Second",
1412            Length::from_pt(72.0),
1413            Length::from_pt(680.0),
1414            Length::from_pt(12.0),
1415        );
1416        let content = String::from_utf8_lossy(&page.content);
1417        assert!(content.contains("First"));
1418        assert!(content.contains("Second"));
1419    }
1420
1421    // ── PdfObject / PdfValue ──────────────────────────────────────────────────
1422
1423    #[test]
1424    fn test_pdf_value_boolean() {
1425        let v = PdfValue::Boolean(true);
1426        if let PdfValue::Boolean(b) = v {
1427            assert!(b);
1428        } else {
1429            panic!("Expected Boolean");
1430        }
1431    }
1432
1433    #[test]
1434    fn test_pdf_value_integer() {
1435        let v = PdfValue::Integer(42);
1436        if let PdfValue::Integer(n) = v {
1437            assert_eq!(n, 42);
1438        } else {
1439            panic!("Expected Integer");
1440        }
1441    }
1442
1443    #[test]
1444    #[allow(clippy::approx_constant)]
1445    fn test_pdf_value_real() {
1446        let v = PdfValue::Real(3.14);
1447        if let PdfValue::Real(f) = v {
1448            assert!((f - 3.14).abs() < f64::EPSILON);
1449        } else {
1450            panic!("Expected Real");
1451        }
1452    }
1453
1454    #[test]
1455    fn test_pdf_value_name() {
1456        let v = PdfValue::Name("Font".to_string());
1457        if let PdfValue::Name(s) = v {
1458            assert_eq!(s, "Font");
1459        } else {
1460            panic!("Expected Name");
1461        }
1462    }
1463
1464    #[test]
1465    fn test_pdf_value_null() {
1466        let v = PdfValue::Null;
1467        assert!(matches!(v, PdfValue::Null));
1468    }
1469
1470    // ── set_compliance errors ─────────────────────────────────────────────────
1471
1472    #[test]
1473    fn test_set_compliance_pdfa_with_encryption_returns_error() {
1474        use crate::pdf::compliance::PdfCompliance;
1475        use crate::pdf::security::{generate_file_id, PdfPermissions, PdfSecurity};
1476        let mut doc = PdfDocument::new();
1477        let sec = PdfSecurity::new("owner", "user", PdfPermissions::default());
1478        let fid = generate_file_id("enc");
1479        let dict = sec.compute_encryption_dict(&fid);
1480        doc.set_encryption(dict, fid).expect("test: should succeed");
1481        let result = doc.set_compliance(PdfCompliance::PdfA1b);
1482        assert!(result.is_err());
1483    }
1484}