Skip to main content

fop_render/pdf/
writer.rs

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