Skip to main content

fop_render/pdf/
writer.rs

1//! PDF renderer - converts area tree to PDF
2
3use super::document::{PdfDocument, PdfPage};
4use super::font_config::FontConfig;
5use super::image::ImageXObject;
6use crate::image::ImageInfo;
7use fop_layout::{AreaId, AreaTree, AreaType};
8use fop_types::{Length, Result};
9use std::collections::HashMap;
10
11use super::document::LinkDestination;
12
13/// PDF renderer
14pub struct PdfRenderer {
15    /// Default page width (A4)
16    #[allow(dead_code)]
17    page_width: Length,
18
19    /// Default page height (A4)
20    #[allow(dead_code)]
21    page_height: Length,
22
23    /// Font configuration: maps family names to TTF file paths
24    font_config: FontConfig,
25}
26
27impl PdfRenderer {
28    /// Create a new PDF renderer with no extra font configuration.
29    ///
30    /// Call [`PdfRenderer::with_font_config`] to supply a custom
31    /// [`FontConfig`], or [`PdfRenderer::with_system_fonts`] to
32    /// auto-discover system fonts.
33    pub fn new() -> Self {
34        Self {
35            page_width: Length::from_mm(210.0),
36            page_height: Length::from_mm(297.0),
37            font_config: FontConfig::new(),
38        }
39    }
40
41    /// Create a new PDF renderer and populate its font configuration from
42    /// standard system font directories.
43    pub fn with_system_fonts() -> Self {
44        Self {
45            page_width: Length::from_mm(210.0),
46            page_height: Length::from_mm(297.0),
47            font_config: FontConfig::with_system_fonts(),
48        }
49    }
50
51    /// Attach a custom [`FontConfig`] to this renderer.
52    ///
53    /// Any existing configuration is replaced.
54    pub fn with_font_config(mut self, font_config: FontConfig) -> Self {
55        self.font_config = font_config;
56        self
57    }
58
59    /// Render an area tree and FO tree to a PDF document (with bookmark support)
60    pub fn render_with_fo(
61        &self,
62        area_tree: &AreaTree,
63        fo_tree: &fop_core::FoArena,
64    ) -> Result<PdfDocument> {
65        let mut doc = self.render(area_tree)?;
66        // Extract bookmarks from the FO tree and add to document
67        if let Ok(Some(outline)) = super::outline::extract_outline_from_fo_tree(fo_tree) {
68            doc.set_outline(outline);
69        }
70        // Set document language from fo:root xml:lang attribute
71        if let Some(ref lang) = fo_tree.document_lang {
72            doc.info.lang = Some(lang.clone());
73        }
74        Ok(doc)
75    }
76
77    /// Render an area tree to a PDF document
78    pub fn render(&self, area_tree: &AreaTree) -> Result<PdfDocument> {
79        let mut doc = PdfDocument::new();
80        doc.info.title = Some("FOP Generated PDF".to_string());
81
82        // First pass: collect all images and add them to the document
83        let mut image_map = HashMap::new();
84        self.collect_images(area_tree, &mut doc, &mut image_map)?;
85
86        // Second pass: collect all opacity values and create ExtGStates
87        let mut opacity_map = HashMap::new();
88        self.collect_opacity_states(area_tree, &mut doc, &mut opacity_map);
89
90        // Third pass: build font cache – scan the area tree for font-family
91        // values and embed each referenced font into the document.
92        let font_cache = self.build_font_cache(area_tree, &mut doc)?;
93
94        // Fourth pass: render pages with image, opacity, and font references
95        for (id, node) in area_tree.iter() {
96            if matches!(node.area.area_type, AreaType::Page) {
97                let page =
98                    self.render_page(area_tree, id, &image_map, &opacity_map, &font_cache)?;
99                doc.add_page(page);
100            }
101        }
102
103        Ok(doc)
104    }
105
106    /// Scan the area tree for all distinct `font-family` values and embed the
107    /// corresponding fonts into `doc`.  Returns a map from family name
108    /// (lowercase) to the embedded font index.
109    fn build_font_cache(
110        &self,
111        area_tree: &AreaTree,
112        doc: &mut PdfDocument,
113    ) -> Result<HashMap<String, usize>> {
114        let mut cache: HashMap<String, usize> = HashMap::new();
115
116        for (_, node) in area_tree.iter() {
117            if let Some(family) = node.area.traits.font_family.as_deref() {
118                let key = family.to_lowercase();
119                if cache.contains_key(&key) {
120                    continue;
121                }
122
123                // Try to find the font file in our configuration
124                if let Some(path) = self.font_config.find_font(family) {
125                    match std::fs::read(path) {
126                        Ok(data) => match doc.embed_font(data) {
127                            Ok(idx) => {
128                                cache.insert(key, idx);
129                            }
130                            Err(_) => {
131                                // Font embedding failed – text will fall back to
132                                // the default Helvetica font.
133                            }
134                        },
135                        Err(_) => {
136                            // File not readable – use default font.
137                        }
138                    }
139                } else {
140                    // No mapping in the config – check if it matches an already
141                    // embedded font by name.
142                    if let Some(idx) = doc.font_manager.find_by_name(family) {
143                        cache.insert(key, idx);
144                    }
145                }
146            }
147        }
148
149        Ok(cache)
150    }
151
152    /// Collect all images from the area tree and add them to the document
153    fn collect_images(
154        &self,
155        area_tree: &AreaTree,
156        doc: &mut PdfDocument,
157        image_map: &mut HashMap<AreaId, usize>,
158    ) -> Result<()> {
159        for (id, node) in area_tree.iter() {
160            // Check if this is a Viewport area (used for images)
161            if matches!(node.area.area_type, AreaType::Viewport) {
162                // Extract image data from the area
163                if let Some(image_data) = node.area.image_data() {
164                    // Add the image to the document
165                    let image_index = self.add_image_from_data(doc, image_data)?;
166                    image_map.insert(id, image_index);
167                }
168            }
169        }
170        Ok(())
171    }
172
173    /// Add an image to the document from raw image data
174    pub fn add_image_from_data(&self, doc: &mut PdfDocument, image_data: &[u8]) -> Result<usize> {
175        let image_info = ImageInfo::from_bytes(image_data)?;
176        let xobject = ImageXObject::from_image_info(&image_info)?;
177        Ok(doc.add_image_xobject(xobject))
178    }
179
180    /// Public wrapper for collect_images (for parallel rendering)
181    pub fn collect_images_public(
182        &self,
183        area_tree: &AreaTree,
184        doc: &mut PdfDocument,
185        image_map: &mut HashMap<AreaId, usize>,
186    ) -> Result<()> {
187        self.collect_images(area_tree, doc, image_map)
188    }
189
190    /// Public wrapper for collect_opacity_states (for parallel rendering)
191    pub fn collect_opacity_states_public(
192        &self,
193        area_tree: &AreaTree,
194        doc: &mut PdfDocument,
195        opacity_map: &mut HashMap<AreaId, usize>,
196    ) {
197        self.collect_opacity_states(area_tree, doc, opacity_map)
198    }
199
200    /// Public wrapper for render_page (for parallel rendering)
201    pub fn render_page_public(
202        &self,
203        area_tree: &AreaTree,
204        page_id: AreaId,
205        image_map: &HashMap<AreaId, usize>,
206        opacity_map: &HashMap<AreaId, usize>,
207        font_cache: &HashMap<String, usize>,
208    ) -> Result<PdfPage> {
209        self.render_page(area_tree, page_id, image_map, opacity_map, font_cache)
210    }
211
212    /// Collect all opacity values from the area tree and create ExtGStates
213    fn collect_opacity_states(
214        &self,
215        area_tree: &AreaTree,
216        doc: &mut PdfDocument,
217        opacity_map: &mut HashMap<AreaId, usize>,
218    ) {
219        for (id, node) in area_tree.iter() {
220            // Check if this area has opacity set
221            if let Some(opacity) = node.area.traits.opacity {
222                if (opacity - 1.0).abs() > f64::EPSILON {
223                    // Add ExtGState for this opacity (both fill and stroke)
224                    let gs_index = doc.add_ext_g_state(opacity, opacity);
225                    opacity_map.insert(id, gs_index);
226                }
227            }
228        }
229    }
230
231    /// Render a single page
232    fn render_page(
233        &self,
234        area_tree: &AreaTree,
235        page_id: AreaId,
236        image_map: &HashMap<AreaId, usize>,
237        opacity_map: &HashMap<AreaId, usize>,
238        font_cache: &HashMap<String, usize>,
239    ) -> Result<PdfPage> {
240        let page_node = area_tree
241            .get(page_id)
242            .ok_or_else(|| fop_types::FopError::Generic("Page not found".to_string()))?;
243
244        let mut pdf_page = PdfPage::new(page_node.area.width(), page_node.area.height());
245
246        // Render all child areas recursively with absolute positioning
247        let page_height = pdf_page.height;
248        render_children(
249            area_tree,
250            page_id,
251            &mut pdf_page,
252            Length::ZERO,
253            Length::ZERO,
254            page_height,
255            image_map,
256            opacity_map,
257            font_cache,
258        )?;
259
260        Ok(pdf_page)
261    }
262}
263
264impl Default for PdfRenderer {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270/// Render child areas recursively with absolute positioning
271#[allow(clippy::too_many_arguments)]
272#[allow(clippy::only_used_in_recursion)]
273fn render_children(
274    area_tree: &AreaTree,
275    parent_id: AreaId,
276    pdf_page: &mut PdfPage,
277    offset_x: Length,
278    offset_y: Length,
279    page_height: Length,
280    image_map: &HashMap<AreaId, usize>,
281    opacity_map: &HashMap<AreaId, usize>,
282    font_cache: &HashMap<String, usize>,
283) -> Result<()> {
284    let children = area_tree.children(parent_id);
285
286    for child_id in children {
287        if let Some(child_node) = area_tree.get(child_id) {
288            // Calculate absolute position
289            let abs_x = offset_x + child_node.area.geometry.x;
290            let abs_y = offset_y + child_node.area.geometry.y;
291
292            // Check if clipping is needed for overflow control
293            let needs_clipping = child_node
294                .area
295                .traits
296                .overflow
297                .map(|o| o.clips_content())
298                .unwrap_or(false);
299
300            // Save state and set clipping if overflow=hidden or scroll
301            if needs_clipping {
302                let pdf_y = page_height - abs_y - child_node.area.height();
303                pdf_page.save_clip_state(
304                    abs_x,
305                    pdf_y,
306                    child_node.area.width(),
307                    child_node.area.height(),
308                )?;
309            }
310
311            // Render background color if present (with optional opacity)
312            if let Some(bg_color) = child_node.area.traits.background_color {
313                let pdf_y = page_height - abs_y - child_node.area.height();
314                let border_radius = child_node.area.traits.border_radius;
315
316                // Check if this area has opacity set
317                if let Some(&gs_index) = opacity_map.get(&child_id) {
318                    // Use opacity-aware rendering
319                    pdf_page.add_background_with_opacity(
320                        abs_x,
321                        pdf_y,
322                        child_node.area.width(),
323                        child_node.area.height(),
324                        bg_color,
325                        border_radius,
326                        gs_index,
327                    );
328                } else {
329                    // Normal rendering without opacity
330                    pdf_page.add_background_with_radius(
331                        abs_x,
332                        pdf_y,
333                        child_node.area.width(),
334                        child_node.area.height(),
335                        bg_color,
336                        border_radius,
337                    );
338                }
339            }
340
341            // Render borders if present (with optional opacity)
342            if let (Some(border_widths), Some(border_colors), Some(border_styles)) = (
343                child_node.area.traits.border_width,
344                child_node.area.traits.border_color,
345                child_node.area.traits.border_style,
346            ) {
347                let pdf_y = page_height - abs_y - child_node.area.height();
348                let border_radius = child_node.area.traits.border_radius;
349
350                // Check if this area has opacity set
351                if let Some(&gs_index) = opacity_map.get(&child_id) {
352                    // Use opacity-aware rendering for borders
353                    pdf_page.add_borders_with_opacity(
354                        abs_x,
355                        pdf_y,
356                        child_node.area.width(),
357                        child_node.area.height(),
358                        border_widths,
359                        border_colors,
360                        border_styles,
361                        border_radius,
362                        gs_index,
363                    );
364                } else {
365                    // Normal rendering without opacity
366                    pdf_page.add_borders_with_radius(
367                        abs_x,
368                        pdf_y,
369                        child_node.area.width(),
370                        child_node.area.height(),
371                        border_widths,
372                        border_colors,
373                        border_styles,
374                        border_radius,
375                    );
376                }
377            }
378
379            match child_node.area.area_type {
380                AreaType::Text => {
381                    // Check if this is a leader area
382                    if let Some(leader_pattern) = &child_node.area.traits.is_leader {
383                        // Render leader based on pattern
384                        render_leader(
385                            pdf_page,
386                            leader_pattern,
387                            abs_x,
388                            abs_y,
389                            child_node.area.width(),
390                            child_node.area.height(),
391                            page_height,
392                            &child_node.area.traits,
393                        );
394                    } else if let Some(text_content) = child_node.area.text_content() {
395                        // Render normal text
396                        let font_size = child_node
397                            .area
398                            .traits
399                            .font_size
400                            .unwrap_or(Length::from_pt(12.0));
401
402                        // Convert from top-left origin to bottom-left origin (PDF coordinate system)
403                        let pdf_y = page_height - abs_y - font_size;
404
405                        // Extract letter-spacing and word-spacing for PDF Tc/Tw operators
406                        let letter_spacing = child_node.area.traits.letter_spacing;
407                        let word_spacing = child_node.area.traits.word_spacing;
408
409                        // Use embedded font if font-family is set and an embedded
410                        // font with that name was found during the font-cache pass.
411                        if let Some(family) = child_node.area.traits.font_family.as_deref() {
412                            if let Some(&font_idx) = font_cache.get(&family.to_lowercase()) {
413                                pdf_page.add_text_with_font_and_spacing(
414                                    text_content,
415                                    abs_x,
416                                    pdf_y,
417                                    font_size,
418                                    font_idx,
419                                    letter_spacing,
420                                    word_spacing,
421                                );
422                            } else {
423                                // Family specified but not embedded – fall back to default
424                                pdf_page.add_text_with_spacing(
425                                    text_content,
426                                    abs_x,
427                                    pdf_y,
428                                    font_size,
429                                    letter_spacing,
430                                    word_spacing,
431                                );
432                            }
433                        } else {
434                            pdf_page.add_text_with_spacing(
435                                text_content,
436                                abs_x,
437                                pdf_y,
438                                font_size,
439                                letter_spacing,
440                                word_spacing,
441                            );
442                        }
443
444                        // Add link annotation if this text has a link destination
445                        if let Some(link_dest) = &child_node.area.traits.link_destination {
446                            let destination = if link_dest.starts_with("http://")
447                                || link_dest.starts_with("https://")
448                                || link_dest.starts_with("mailto:")
449                            {
450                                LinkDestination::External(link_dest.clone())
451                            } else {
452                                LinkDestination::Internal(link_dest.clone())
453                            };
454
455                            // Add annotation for the text area
456                            pdf_page.add_link_annotation(
457                                abs_x,
458                                pdf_y,
459                                child_node.area.width(),
460                                font_size,
461                                destination,
462                            );
463                        }
464                    }
465                }
466                AreaType::Inline => {
467                    // Check if this is a leader area without text content
468                    if let Some(leader_pattern) = &child_node.area.traits.is_leader {
469                        render_leader(
470                            pdf_page,
471                            leader_pattern,
472                            abs_x,
473                            abs_y,
474                            child_node.area.width(),
475                            child_node.area.height(),
476                            page_height,
477                            &child_node.area.traits,
478                        );
479                    } else {
480                        // Check if this inline area has a link destination
481                        if let Some(link_dest) = &child_node.area.traits.link_destination {
482                            let destination = if link_dest.starts_with("http://")
483                                || link_dest.starts_with("https://")
484                                || link_dest.starts_with("mailto:")
485                            {
486                                LinkDestination::External(link_dest.clone())
487                            } else {
488                                LinkDestination::Internal(link_dest.clone())
489                            };
490
491                            // Convert to PDF coordinates
492                            let pdf_y = page_height - abs_y - child_node.area.height();
493
494                            // Add annotation for the inline area
495                            pdf_page.add_link_annotation(
496                                abs_x,
497                                pdf_y,
498                                child_node.area.width(),
499                                child_node.area.height(),
500                                destination,
501                            );
502                        }
503
504                        // Recursively render inline areas children
505                        render_children(
506                            area_tree,
507                            child_id,
508                            pdf_page,
509                            abs_x,
510                            abs_y,
511                            page_height,
512                            image_map,
513                            opacity_map,
514                            font_cache,
515                        )?;
516                    }
517                }
518                AreaType::Viewport => {
519                    // Render image if this viewport has an associated image
520                    if let Some(&image_index) = image_map.get(&child_id) {
521                        let pdf_y = page_height - abs_y - child_node.area.height();
522                        pdf_page.add_image(
523                            image_index,
524                            abs_x,
525                            pdf_y,
526                            child_node.area.width(),
527                            child_node.area.height(),
528                        );
529                    }
530                    // Still recurse in case there are child areas
531                    render_children(
532                        area_tree,
533                        child_id,
534                        pdf_page,
535                        abs_x,
536                        abs_y,
537                        page_height,
538                        image_map,
539                        opacity_map,
540                        font_cache,
541                    )?;
542                }
543                _ => {
544                    // Recursively render other areas with accumulated offsets
545                    render_children(
546                        area_tree,
547                        child_id,
548                        pdf_page,
549                        abs_x,
550                        abs_y,
551                        page_height,
552                        image_map,
553                        opacity_map,
554                        font_cache,
555                    )?;
556                }
557            }
558
559            // Restore graphics state if clipping was applied
560            if needs_clipping {
561                pdf_page.restore_clip_state()?;
562            }
563        }
564    }
565
566    Ok(())
567}
568
569/// Render a leader (dots, rule, space, etc.)
570#[allow(clippy::too_many_arguments)]
571fn render_leader(
572    pdf_page: &mut PdfPage,
573    leader_pattern: &str,
574    x: Length,
575    y: Length,
576    width: Length,
577    height: Length,
578    page_height: Length,
579    traits: &fop_layout::area::TraitSet,
580) {
581    match leader_pattern {
582        "rule" => {
583            // Render as a horizontal line
584            let thickness = traits.rule_thickness.unwrap_or(Length::from_pt(0.5));
585
586            let style = traits.rule_style.as_deref().unwrap_or("solid");
587
588            // Center the rule vertically within the leader area
589            let half_diff = Length::from_millipoints((height - thickness).millipoints() / 2);
590            let rule_y = y + half_diff;
591
592            // Convert to PDF coordinates (bottom-left origin)
593            let pdf_y = page_height - rule_y - thickness;
594
595            // Get color from traits or default to black
596            let color = traits.color.unwrap_or(fop_types::Color::BLACK);
597
598            pdf_page.add_rule(x, pdf_y, width, thickness, color, style);
599        }
600        "dots" => {
601            // This is already handled by text rendering with the dot pattern
602            // The layout engine generates the dot string
603        }
604        "space" => {
605            // Space leaders render nothing (just occupy space)
606        }
607        _ => {
608            // Unknown pattern, default to space behavior
609        }
610    }
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616    use fop_layout::{Area, AreaTree};
617    use fop_types::{Point, Rect, Size};
618
619    #[test]
620    fn test_renderer_creation() {
621        let renderer = PdfRenderer::new();
622        assert_eq!(renderer.page_width, Length::from_mm(210.0));
623        assert_eq!(renderer.page_height, Length::from_mm(297.0));
624    }
625
626    #[test]
627    fn test_render_empty_tree() {
628        let renderer = PdfRenderer::new();
629        let tree = AreaTree::new();
630
631        let doc = renderer.render(&tree).expect("test: should succeed");
632        assert_eq!(doc.pages.len(), 0);
633    }
634
635    #[test]
636    fn test_render_single_page() {
637        let renderer = PdfRenderer::new();
638        let mut tree = AreaTree::new();
639
640        // Create a page area
641        let page_rect = Rect::from_point_size(
642            Point::ZERO,
643            Size::new(Length::from_mm(210.0), Length::from_mm(297.0)),
644        );
645        let page = Area::new(AreaType::Page, page_rect);
646        tree.add_area(page);
647
648        let doc = renderer.render(&tree).expect("test: should succeed");
649        assert_eq!(doc.pages.len(), 1);
650    }
651
652    #[test]
653    fn test_add_image_to_document() {
654        let renderer = PdfRenderer::new();
655        let mut doc = PdfDocument::new();
656
657        // Create a minimal valid PNG image (1x1 red pixel)
658        let mut png_data = Vec::new();
659        let mut encoder = png::Encoder::new(&mut png_data, 1, 1);
660        encoder.set_color(png::ColorType::Rgb);
661        encoder.set_depth(png::BitDepth::Eight);
662
663        let mut writer = encoder.write_header().expect("test: should succeed");
664        let data = vec![255, 0, 0]; // Red pixel
665        writer
666            .write_image_data(&data)
667            .expect("test: should succeed");
668        drop(writer);
669
670        // Add the image to the document
671        let image_index = renderer
672            .add_image_from_data(&mut doc, &png_data)
673            .expect("test: should succeed");
674        assert_eq!(image_index, 0);
675        assert_eq!(doc.image_xobjects.len(), 1);
676
677        // Verify the image XObject properties
678        let xobject = &doc.image_xobjects[0];
679        assert_eq!(xobject.width, 1);
680        assert_eq!(xobject.height, 1);
681        assert_eq!(xobject.color_space, "DeviceRGB");
682        assert_eq!(xobject.filter, "FlateDecode");
683    }
684
685    #[test]
686    fn test_pdf_with_image_generates_valid_bytes() {
687        let renderer = PdfRenderer::new();
688        let mut doc = PdfDocument::new();
689
690        // Create a minimal PNG
691        let mut png_data = Vec::new();
692        let mut encoder = png::Encoder::new(&mut png_data, 2, 2);
693        encoder.set_color(png::ColorType::Rgb);
694        encoder.set_depth(png::BitDepth::Eight);
695
696        let mut writer = encoder.write_header().expect("test: should succeed");
697        let data = vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]; // 2x2 RGBW
698        writer
699            .write_image_data(&data)
700            .expect("test: should succeed");
701        drop(writer);
702
703        // Add image and a page
704        renderer
705            .add_image_from_data(&mut doc, &png_data)
706            .expect("test: should succeed");
707
708        let mut page = super::PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
709        page.add_image(
710            0,
711            Length::from_pt(100.0),
712            Length::from_pt(100.0),
713            Length::from_pt(50.0),
714            Length::from_pt(50.0),
715        );
716        doc.add_page(page);
717
718        // Generate PDF bytes
719        let bytes = doc.to_bytes().expect("test: should succeed");
720
721        // Verify PDF structure
722        let pdf_str = String::from_utf8_lossy(&bytes);
723        assert!(pdf_str.starts_with("%PDF-"));
724        assert!(pdf_str.contains("/Type /XObject"));
725        assert!(pdf_str.contains("/Subtype /Image"));
726        assert!(pdf_str.contains("/Filter /FlateDecode"));
727        assert!(pdf_str.contains("/Im0 Do")); // Image rendering command
728        assert!(pdf_str.contains("%%EOF"));
729    }
730}
731
732#[cfg(test)]
733mod tests_writer_comprehensive {
734    use super::*;
735    use fop_layout::{Area, AreaTree, AreaType};
736    use fop_types::{Length, Point, Rect, Size};
737
738    fn make_page_area(w_mm: f64, h_mm: f64) -> Area {
739        let rect = Rect::from_point_size(
740            Point::ZERO,
741            Size::new(Length::from_mm(w_mm), Length::from_mm(h_mm)),
742        );
743        Area::new(AreaType::Page, rect)
744    }
745
746    // ── PdfRenderer::new() ────────────────────────────────────────────────────
747
748    #[test]
749    fn test_renderer_new_default_page_width() {
750        let r = PdfRenderer::new();
751        assert_eq!(r.page_width, Length::from_mm(210.0));
752    }
753
754    #[test]
755    fn test_renderer_new_default_page_height() {
756        let r = PdfRenderer::new();
757        assert_eq!(r.page_height, Length::from_mm(297.0));
758    }
759
760    #[test]
761    fn test_renderer_default_equals_new() {
762        let r1 = PdfRenderer::new();
763        let r2 = PdfRenderer::default();
764        assert_eq!(r1.page_width, r2.page_width);
765        assert_eq!(r1.page_height, r2.page_height);
766    }
767
768    // ── render() on empty tree ────────────────────────────────────────────────
769
770    #[test]
771    fn test_render_empty_tree_no_pages() {
772        let r = PdfRenderer::new();
773        let tree = AreaTree::new();
774        let doc = r.render(&tree).expect("test: should succeed");
775        assert_eq!(doc.pages.len(), 0);
776    }
777
778    #[test]
779    fn test_render_empty_tree_produces_valid_pdf() {
780        let r = PdfRenderer::new();
781        let tree = AreaTree::new();
782        let doc = r.render(&tree).expect("test: should succeed");
783        let bytes = doc.to_bytes().expect("test: should succeed");
784        assert!(bytes.starts_with(b"%PDF-"));
785    }
786
787    // ── render() with pages ───────────────────────────────────────────────────
788
789    #[test]
790    fn test_render_one_page_produces_one_page_doc() {
791        let r = PdfRenderer::new();
792        let mut tree = AreaTree::new();
793        tree.add_area(make_page_area(210.0, 297.0));
794        let doc = r.render(&tree).expect("test: should succeed");
795        assert_eq!(doc.pages.len(), 1);
796    }
797
798    #[test]
799    fn test_render_two_pages_produces_two_page_doc() {
800        let r = PdfRenderer::new();
801        let mut tree = AreaTree::new();
802        tree.add_area(make_page_area(210.0, 297.0));
803        tree.add_area(make_page_area(210.0, 297.0));
804        let doc = r.render(&tree).expect("test: should succeed");
805        assert_eq!(doc.pages.len(), 2);
806    }
807
808    #[test]
809    fn test_render_five_pages_produces_five_page_doc() {
810        let r = PdfRenderer::new();
811        let mut tree = AreaTree::new();
812        for _ in 0..5 {
813            tree.add_area(make_page_area(210.0, 297.0));
814        }
815        let doc = r.render(&tree).expect("test: should succeed");
816        assert_eq!(doc.pages.len(), 5);
817    }
818
819    #[test]
820    fn test_render_page_count_in_catalog_bytes() {
821        let r = PdfRenderer::new();
822        let mut tree = AreaTree::new();
823        tree.add_area(make_page_area(210.0, 297.0));
824        tree.add_area(make_page_area(210.0, 297.0));
825        let doc = r.render(&tree).expect("test: should succeed");
826        let bytes = doc.to_bytes().expect("test: should succeed");
827        let s = String::from_utf8_lossy(&bytes);
828        assert!(s.contains("/Count 2"));
829    }
830
831    // ── render() sets title ───────────────────────────────────────────────────
832
833    #[test]
834    fn test_render_sets_default_title() {
835        let r = PdfRenderer::new();
836        let tree = AreaTree::new();
837        let doc = r.render(&tree).expect("test: should succeed");
838        assert_eq!(doc.info.title.as_deref(), Some("FOP Generated PDF"));
839    }
840
841    // ── render() output is valid PDF ──────────────────────────────────────────
842
843    #[test]
844    fn test_render_output_has_eof_marker() {
845        let r = PdfRenderer::new();
846        let tree = AreaTree::new();
847        let doc = r.render(&tree).expect("test: should succeed");
848        let bytes = doc.to_bytes().expect("test: should succeed");
849        let s = String::from_utf8_lossy(&bytes);
850        assert!(s.contains("%%EOF"));
851    }
852
853    #[test]
854    fn test_render_output_has_catalog() {
855        let r = PdfRenderer::new();
856        let tree = AreaTree::new();
857        let doc = r.render(&tree).expect("test: should succeed");
858        let bytes = doc.to_bytes().expect("test: should succeed");
859        let s = String::from_utf8_lossy(&bytes);
860        assert!(s.contains("/Type /Catalog"));
861    }
862
863    #[test]
864    fn test_render_output_has_pages_dict() {
865        let r = PdfRenderer::new();
866        let tree = AreaTree::new();
867        let doc = r.render(&tree).expect("test: should succeed");
868        let bytes = doc.to_bytes().expect("test: should succeed");
869        let s = String::from_utf8_lossy(&bytes);
870        assert!(s.contains("/Type /Pages"));
871    }
872
873    #[test]
874    fn test_render_output_has_font_resource() {
875        let r = PdfRenderer::new();
876        let tree = AreaTree::new();
877        let doc = r.render(&tree).expect("test: should succeed");
878        let bytes = doc.to_bytes().expect("test: should succeed");
879        let s = String::from_utf8_lossy(&bytes);
880        // Default Helvetica font should be present
881        assert!(s.contains("/BaseFont /Helvetica"));
882    }
883
884    // ── add_image_from_data() ─────────────────────────────────────────────────
885
886    fn make_png_1x1_red() -> Vec<u8> {
887        let mut buf = Vec::new();
888        let mut enc = png::Encoder::new(&mut buf, 1, 1);
889        enc.set_color(png::ColorType::Rgb);
890        enc.set_depth(png::BitDepth::Eight);
891        let mut w = enc.write_header().expect("test: should succeed");
892        w.write_image_data(&[255, 0, 0])
893            .expect("test: should succeed");
894        drop(w);
895        buf
896    }
897
898    #[test]
899    fn test_add_image_returns_index_zero_for_first() {
900        let r = PdfRenderer::new();
901        let mut doc = super::PdfDocument::new();
902        let idx = r
903            .add_image_from_data(&mut doc, &make_png_1x1_red())
904            .expect("test: should succeed");
905        assert_eq!(idx, 0);
906    }
907
908    #[test]
909    fn test_add_image_increments_index_for_second() {
910        let r = PdfRenderer::new();
911        let mut doc = super::PdfDocument::new();
912        r.add_image_from_data(&mut doc, &make_png_1x1_red())
913            .expect("test: should succeed");
914        let idx2 = r
915            .add_image_from_data(&mut doc, &make_png_1x1_red())
916            .expect("test: should succeed");
917        assert_eq!(idx2, 1);
918    }
919
920    #[test]
921    fn test_add_image_grows_image_xobjects() {
922        let r = PdfRenderer::new();
923        let mut doc = super::PdfDocument::new();
924        r.add_image_from_data(&mut doc, &make_png_1x1_red())
925            .expect("test: should succeed");
926        r.add_image_from_data(&mut doc, &make_png_1x1_red())
927            .expect("test: should succeed");
928        assert_eq!(doc.image_xobjects.len(), 2);
929    }
930
931    // ── collect_images_public() ───────────────────────────────────────────────
932
933    #[test]
934    fn test_collect_images_public_empty_tree_no_images() {
935        let r = PdfRenderer::new();
936        let tree = AreaTree::new();
937        let mut doc = super::PdfDocument::new();
938        let mut map = HashMap::new();
939        r.collect_images_public(&tree, &mut doc, &mut map)
940            .expect("test: should succeed");
941        assert!(doc.image_xobjects.is_empty());
942        assert!(map.is_empty());
943    }
944
945    // ── collect_opacity_states_public() ──────────────────────────────────────
946
947    #[test]
948    fn test_collect_opacity_states_empty_tree_no_states() {
949        let r = PdfRenderer::new();
950        let tree = AreaTree::new();
951        let mut doc = super::PdfDocument::new();
952        let mut map = HashMap::new();
953        r.collect_opacity_states_public(&tree, &mut doc, &mut map);
954        assert!(doc.ext_g_states.is_empty());
955        assert!(map.is_empty());
956    }
957
958    // ── render_page_public() ─────────────────────────────────────────────────
959
960    #[test]
961    fn test_render_page_public_produces_correct_dimensions() {
962        let r = PdfRenderer::new();
963        let mut tree = AreaTree::new();
964        let page_id = tree.add_area(make_page_area(210.0, 297.0));
965        let doc = super::PdfDocument::new();
966        let _ = doc; // doc not needed for page render directly
967        let img_map = HashMap::new();
968        let op_map = HashMap::new();
969        let font_cache = HashMap::new();
970        let page = r
971            .render_page_public(&tree, page_id, &img_map, &op_map, &font_cache)
972            .expect("test: should succeed");
973        assert_eq!(page.width, Length::from_mm(210.0));
974        assert_eq!(page.height, Length::from_mm(297.0));
975    }
976
977    // ── with_system_fonts() ───────────────────────────────────────────────────
978
979    #[test]
980    fn test_with_system_fonts_can_render_empty_tree() {
981        let r = PdfRenderer::with_system_fonts();
982        let tree = AreaTree::new();
983        let doc = r.render(&tree).expect("test: should succeed");
984        assert_eq!(doc.pages.len(), 0);
985    }
986
987    // ── Full round-trip: render + to_bytes ────────────────────────────────────
988
989    #[test]
990    fn test_full_round_trip_single_page_pdf_is_valid() {
991        let r = PdfRenderer::new();
992        let mut tree = AreaTree::new();
993        tree.add_area(make_page_area(210.0, 297.0));
994        let doc = r.render(&tree).expect("test: should succeed");
995        let bytes = doc.to_bytes().expect("test: should succeed");
996        let s = String::from_utf8_lossy(&bytes);
997        assert!(s.starts_with("%PDF-"));
998        assert!(s.contains("%%EOF"));
999        assert!(s.contains("/Count 1"));
1000    }
1001
1002    #[test]
1003    fn test_a5_page_dimensions_in_media_box() {
1004        let r = PdfRenderer::new();
1005        let mut tree = AreaTree::new();
1006        // A5 is 148x210mm
1007        tree.add_area(make_page_area(148.0, 210.0));
1008        let doc = r.render(&tree).expect("test: should succeed");
1009        let bytes = doc.to_bytes().expect("test: should succeed");
1010        let s = String::from_utf8_lossy(&bytes);
1011        assert!(s.contains("/MediaBox"));
1012    }
1013}