Skip to main content

fop_render/ps/
mod.rs

1//! PostScript rendering backend
2//!
3//! Generates PostScript Level 2 documents from area trees.
4
5use crate::image::ImageInfo;
6use fop_layout::{AreaId, AreaTree, AreaType};
7use fop_types::{Color, Length, Result};
8use std::collections::HashMap;
9
10/// PostScript renderer
11pub struct PsRenderer {
12    /// Default page width (A4)
13    #[allow(dead_code)]
14    page_width: Length,
15
16    /// Default page height (A4)
17    #[allow(dead_code)]
18    page_height: Length,
19}
20
21impl PsRenderer {
22    /// Create a new PostScript renderer
23    pub fn new() -> Self {
24        Self {
25            page_width: Length::from_mm(210.0),
26            page_height: Length::from_mm(297.0),
27        }
28    }
29
30    /// Render an area tree to a PostScript document (returns PS as String)
31    pub fn render_to_ps(&self, area_tree: &AreaTree) -> Result<String> {
32        let mut ps_doc = PsDocument::new();
33
34        // First pass: collect all images and convert to PS format
35        let mut image_map = HashMap::new();
36        self.collect_images(area_tree, &mut image_map)?;
37
38        // Second pass: render pages
39        for (id, node) in area_tree.iter() {
40            if matches!(node.area.area_type, AreaType::Page) {
41                let page_ps = self.render_page(area_tree, id, &image_map)?;
42                ps_doc.add_page(page_ps);
43            }
44        }
45
46        Ok(ps_doc.to_string())
47    }
48
49    /// Collect all images from the area tree
50    fn collect_images(
51        &self,
52        area_tree: &AreaTree,
53        image_map: &mut HashMap<AreaId, ImageData>,
54    ) -> Result<()> {
55        for (id, node) in area_tree.iter() {
56            if matches!(node.area.area_type, AreaType::Viewport) {
57                if let Some(image_data) = node.area.image_data() {
58                    let image_info = ImageInfo::from_bytes(image_data)?;
59                    let ps_image = ImageData {
60                        width: image_info.width_px,
61                        height: image_info.height_px,
62                        data: image_data.to_vec(),
63                    };
64                    image_map.insert(id, ps_image);
65                }
66            }
67        }
68        Ok(())
69    }
70
71    /// Render a single page
72    fn render_page(
73        &self,
74        area_tree: &AreaTree,
75        page_id: AreaId,
76        image_map: &HashMap<AreaId, ImageData>,
77    ) -> Result<PsPage> {
78        let page_node = area_tree
79            .get(page_id)
80            .ok_or_else(|| fop_types::FopError::Generic("Page not found".to_string()))?;
81
82        let width = page_node.area.width();
83        let height = page_node.area.height();
84
85        let mut ps_page = PsPage::new(width, height);
86
87        // Render all child areas recursively with absolute positioning
88        render_children(
89            area_tree,
90            page_id,
91            &mut ps_page,
92            Length::ZERO,
93            Length::ZERO,
94            image_map,
95        )?;
96
97        Ok(ps_page)
98    }
99}
100
101impl Default for PsRenderer {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107/// Image data for PostScript
108#[derive(Clone)]
109struct ImageData {
110    width: u32,
111    height: u32,
112    #[allow(dead_code)]
113    data: Vec<u8>,
114}
115
116/// PostScript document builder - manages multiple pages
117pub struct PsDocument {
118    pages: Vec<PsPage>,
119}
120
121impl PsDocument {
122    /// Create a new PostScript document
123    pub fn new() -> Self {
124        Self { pages: Vec::new() }
125    }
126
127    /// Add a page to the document
128    pub fn add_page(&mut self, page: PsPage) {
129        self.pages.push(page);
130    }
131
132    /// Write DSC header comments for the document.
133    ///
134    /// The `%%BoundingBox` is set to the union of all page bounding boxes,
135    /// which for a typical document is the bounding box of the first (or
136    /// largest) page.
137    fn write_dsc_header(&self, result: &mut String) {
138        result.push_str("%!PS-Adobe-3.0\n");
139        result.push_str("%%Creator: Apache FOP Rust\n");
140        result.push_str("%%LanguageLevel: 2\n");
141        result.push_str(&format!("%%Pages: {}\n", self.pages.len()));
142
143        // Compute global bounding box (union of all pages)
144        if let Some(first_page) = self.pages.first() {
145            let max_w = self
146                .pages
147                .iter()
148                .map(|p| p.width.to_pt() as i64)
149                .max()
150                .unwrap_or(0);
151            let max_h = self
152                .pages
153                .iter()
154                .map(|p| p.height.to_pt() as i64)
155                .max()
156                .unwrap_or(0);
157            // Suppress unused warning: first_page is used to initialise max
158            let _ = first_page;
159            result.push_str(&format!("%%BoundingBox: 0 0 {} {}\n", max_w, max_h));
160        } else {
161            result.push_str("%%BoundingBox: (atend)\n");
162        }
163
164        result.push_str("%%DocumentNeededResources: font Helvetica Helvetica-Bold Helvetica-Oblique Helvetica-BoldOblique Times-Roman Courier\n");
165        result.push_str("%%EndComments\n");
166        result.push('\n');
167    }
168
169    /// Write the prolog section with procedure definitions and font encoding.
170    fn write_prolog(&self, result: &mut String) {
171        result.push_str("%%BeginProlog\n");
172
173        // Short-hand operators
174        result.push_str("/M { moveto } bind def\n");
175        result.push_str("/L { lineto } bind def\n");
176        result.push_str("/S { stroke } bind def\n");
177        result.push_str("/F { fill } bind def\n");
178        result.push_str("/NP { newpath } bind def\n");
179        result.push_str("/CP { closepath } bind def\n");
180        result.push_str("/RGB { setrgbcolor } bind def\n");
181        result.push_str("/GRAY { setgray } bind def\n");
182        result.push_str("/LW { setlinewidth } bind def\n");
183        result.push_str("/SF { setfont } bind def\n");
184        result.push_str("/SH { show } bind def\n");
185        result.push('\n');
186
187        // Rectangle helpers
188        result.push_str("% rect: x y w h -> path\n");
189        result.push_str("/rect {\n");
190        result.push_str("  /h exch def /w exch def /y exch def /x exch def\n");
191        result.push_str("  NP x y M x w add y L x w add y h add L x y h add L CP\n");
192        result.push_str("} bind def\n");
193        result.push('\n');
194
195        result.push_str("% frect: x y w h -- filled rectangle\n");
196        result.push_str("/frect {\n");
197        result.push_str("  /h exch def /w exch def /y exch def /x exch def\n");
198        result.push_str("  NP x y M x w add y L x w add y h add L x y h add L CP F\n");
199        result.push_str("} bind def\n");
200        result.push('\n');
201
202        result.push_str("% srect: x y w h -- stroked rectangle\n");
203        result.push_str("/srect {\n");
204        result.push_str("  /h exch def /w exch def /y exch def /x exch def\n");
205        result.push_str("  NP x y M x w add y L x w add y h add L x y h add L CP S\n");
206        result.push_str("} bind def\n");
207        result.push('\n');
208
209        // Font selection helper: FN fontname fontsize -- selects and scales font
210        result.push_str("% FN: /FontName size -> (select and set font)\n");
211        result.push_str("/FN {\n");
212        result.push_str("  exch findfont exch scalefont SF\n");
213        result.push_str("} bind def\n");
214        result.push('\n');
215
216        result.push_str("%%EndProlog\n");
217        result.push('\n');
218    }
219
220    /// Write the setup section (shared resources / font encoding).
221    fn write_setup(&self, result: &mut String) {
222        result.push_str("%%BeginSetup\n");
223
224        // Re-encode standard fonts to ISOLatin1 for proper character support
225        for font_name in &[
226            "Helvetica",
227            "Helvetica-Bold",
228            "Helvetica-Oblique",
229            "Helvetica-BoldOblique",
230            "Times-Roman",
231            "Times-Bold",
232            "Courier",
233        ] {
234            result.push_str(&format!(
235                "/{fname} findfont\n\
236                 dup length dict begin\n\
237                   {{ 1 index /FID ne {{ def }} {{ pop pop }} ifelse }} forall\n\
238                   /Encoding ISOLatin1Encoding def\n\
239                   currentdict\n\
240                 end\n\
241                 /{fname} exch definefont pop\n\n",
242                fname = font_name
243            ));
244        }
245
246        result.push_str("%%EndSetup\n");
247        result.push('\n');
248    }
249
250    /// Write the DSC `%%Page` header for a single page.
251    fn write_page_header(page_num: usize, total_pages: usize, page: &PsPage, result: &mut String) {
252        result.push_str(&format!("%%Page: {} {}\n", page_num, total_pages));
253
254        let w = page.width.to_pt() as i64;
255        let h = page.height.to_pt() as i64;
256        result.push_str(&format!("%%PageBoundingBox: 0 0 {} {}\n", w, h));
257        result.push_str("%%PageOrientation: Portrait\n");
258        result.push_str("%%BeginPageSetup\n");
259        result.push_str(&format!("<< /PageSize [{} {}] >> setpagedevice\n", w, h));
260        result.push_str("%%EndPageSetup\n");
261    }
262
263    /// Convert to PostScript string
264    fn build_ps(&self) -> String {
265        let mut result = String::new();
266        let total = self.pages.len();
267
268        // --- DSC Header ---
269        self.write_dsc_header(&mut result);
270
271        // --- Prolog ---
272        self.write_prolog(&mut result);
273
274        // --- Setup ---
275        self.write_setup(&mut result);
276
277        // --- Pages ---
278        for (i, page) in self.pages.iter().enumerate() {
279            Self::write_page_header(i + 1, total, page, &mut result);
280            result.push_str(&page.to_string());
281            result.push_str("showpage\n");
282            result.push('\n');
283        }
284
285        // --- Trailer ---
286        result.push_str("%%Trailer\n");
287        result.push_str(&format!("%%Pages: {}\n", total));
288        result.push_str("%%EOF\n");
289
290        result
291    }
292}
293
294impl Default for PsDocument {
295    fn default() -> Self {
296        Self::new()
297    }
298}
299
300impl std::fmt::Display for PsDocument {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        write!(f, "{}", self.build_ps())
303    }
304}
305
306/// PostScript page builder
307pub struct PsPage {
308    width: Length,
309    height: Length,
310    commands: Vec<String>,
311}
312
313impl PsPage {
314    /// Create a new PostScript page
315    pub fn new(width: Length, height: Length) -> Self {
316        // Save graphics state at the start of every page
317        let commands = vec!["gsave".to_string()];
318
319        Self {
320            width,
321            height,
322            commands,
323        }
324    }
325
326    /// Add a filled rectangle (for backgrounds)
327    #[allow(clippy::too_many_arguments)]
328    pub fn add_background(
329        &mut self,
330        x: Length,
331        y: Length,
332        width: Length,
333        height: Length,
334        color: Color,
335    ) {
336        // Set fill color
337        self.set_color(color);
338
339        // Draw filled rectangle
340        self.commands.push(format!(
341            "{:.3} {:.3} {:.3} {:.3} frect",
342            x.to_pt(),
343            y.to_pt(),
344            width.to_pt(),
345            height.to_pt()
346        ));
347    }
348
349    /// Add borders
350    #[allow(clippy::too_many_arguments)]
351    pub fn add_borders(
352        &mut self,
353        x: Length,
354        y: Length,
355        width: Length,
356        height: Length,
357        border_widths: [Length; 4],
358        border_colors: [Color; 4],
359        border_styles: [fop_layout::area::BorderStyle; 4],
360    ) {
361        use fop_layout::area::BorderStyle;
362
363        let [top_w, right_w, bottom_w, left_w] = border_widths;
364        let [top_c, right_c, bottom_c, left_c] = border_colors;
365        let [top_s, right_s, bottom_s, left_s] = border_styles;
366
367        // Top border
368        if top_w.to_pt() > 0.0 && !matches!(top_s, BorderStyle::None | BorderStyle::Hidden) {
369            self.set_color(top_c);
370            self.commands.push(format!(
371                "{:.3} {:.3} {:.3} {:.3} frect",
372                x.to_pt(),
373                (y + height - top_w).to_pt(),
374                width.to_pt(),
375                top_w.to_pt()
376            ));
377        }
378
379        // Right border
380        if right_w.to_pt() > 0.0 && !matches!(right_s, BorderStyle::None | BorderStyle::Hidden) {
381            self.set_color(right_c);
382            self.commands.push(format!(
383                "{:.3} {:.3} {:.3} {:.3} frect",
384                (x + width - right_w).to_pt(),
385                y.to_pt(),
386                right_w.to_pt(),
387                height.to_pt()
388            ));
389        }
390
391        // Bottom border
392        if bottom_w.to_pt() > 0.0 && !matches!(bottom_s, BorderStyle::None | BorderStyle::Hidden) {
393            self.set_color(bottom_c);
394            self.commands.push(format!(
395                "{:.3} {:.3} {:.3} {:.3} frect",
396                x.to_pt(),
397                y.to_pt(),
398                width.to_pt(),
399                bottom_w.to_pt()
400            ));
401        }
402
403        // Left border
404        if left_w.to_pt() > 0.0 && !matches!(left_s, BorderStyle::None | BorderStyle::Hidden) {
405            self.set_color(left_c);
406            self.commands.push(format!(
407                "{:.3} {:.3} {:.3} {:.3} frect",
408                x.to_pt(),
409                y.to_pt(),
410                left_w.to_pt(),
411                height.to_pt()
412            ));
413        }
414    }
415
416    /// Add text using the improved `FN` helper.
417    ///
418    /// Font selection: bold/italic variants are chosen from `traits`; when no
419    /// trait information is available the base Helvetica family is used.
420    pub fn add_text(
421        &mut self,
422        text: &str,
423        x: Length,
424        y: Length,
425        font_size: Length,
426        color: Option<Color>,
427    ) {
428        // Set text color
429        let c = color.unwrap_or(Color::BLACK);
430        self.set_color(c);
431
432        // Font selection (Helvetica family — no trait info at this call site)
433        let font_name = "/Helvetica";
434        self.commands
435            .push(format!("{} {:.3} FN", font_name, font_size.to_pt()));
436
437        // Escape PostScript special characters
438        let escaped_text = escape_ps_string(text);
439
440        // Position and show text
441        self.commands.push(format!(
442            "{:.3} {:.3} M ({}) SH",
443            x.to_pt(),
444            y.to_pt(),
445            escaped_text
446        ));
447    }
448
449    /// Add text with explicit font-weight / font-style information.
450    #[allow(clippy::too_many_arguments)]
451    pub fn add_text_styled(
452        &mut self,
453        text: &str,
454        x: Length,
455        y: Length,
456        font_size: Length,
457        color: Option<Color>,
458        bold: bool,
459        italic: bool,
460    ) {
461        let c = color.unwrap_or(Color::BLACK);
462        self.set_color(c);
463
464        let font_name = select_ps_font("Helvetica", bold, italic);
465        self.commands
466            .push(format!("{} {:.3} FN", font_name, font_size.to_pt()));
467
468        let escaped_text = escape_ps_string(text);
469        self.commands.push(format!(
470            "{:.3} {:.3} M ({}) SH",
471            x.to_pt(),
472            y.to_pt(),
473            escaped_text
474        ));
475    }
476
477    /// Add a line (for rules, borders)
478    #[allow(clippy::too_many_arguments)]
479    pub fn add_line(
480        &mut self,
481        x1: Length,
482        y1: Length,
483        x2: Length,
484        y2: Length,
485        color: Color,
486        width: Length,
487        _style: &str,
488    ) {
489        self.set_color(color);
490        self.commands.push(format!("{:.3} LW", width.to_pt()));
491        self.commands.push("NP".to_string());
492        self.commands
493            .push(format!("{:.3} {:.3} M", x1.to_pt(), y1.to_pt()));
494        self.commands
495            .push(format!("{:.3} {:.3} L", x2.to_pt(), y2.to_pt()));
496        self.commands.push("S".to_string());
497    }
498
499    /// Add an image
500    #[allow(dead_code)]
501    fn add_image(
502        &mut self,
503        image_data: &ImageData,
504        x: Length,
505        y: Length,
506        width: Length,
507        height: Length,
508    ) {
509        // Save graphics state
510        self.commands.push("gsave".to_string());
511
512        // Set up transformation matrix for the image
513        self.commands
514            .push(format!("{:.3} {:.3} translate", x.to_pt(), y.to_pt()));
515        self.commands
516            .push(format!("{:.3} {:.3} scale", width.to_pt(), height.to_pt()));
517
518        // Image dictionary — full embedding is complex; draw a placeholder rectangle
519        self.commands.push(format!(
520            "% image placeholder {}x{}",
521            image_data.width, image_data.height
522        ));
523        self.commands.push("grestore".to_string());
524
525        // Placeholder rectangle
526        self.commands.push("0.9 GRAY".to_string());
527        self.commands.push(format!(
528            "{:.3} {:.3} {:.3} {:.3} frect",
529            x.to_pt(),
530            y.to_pt(),
531            width.to_pt(),
532            height.to_pt()
533        ));
534    }
535
536    /// Set clipping rectangle
537    pub fn save_clip_state(&mut self, x: Length, y: Length, width: Length, height: Length) {
538        self.commands.push("gsave".to_string());
539        self.commands.push("NP".to_string());
540        self.commands.push(format!(
541            "{:.3} {:.3} {:.3} {:.3} rect",
542            x.to_pt(),
543            y.to_pt(),
544            width.to_pt(),
545            height.to_pt()
546        ));
547        self.commands.push("clip".to_string());
548    }
549
550    /// Restore graphics state
551    pub fn restore_clip_state(&mut self) {
552        self.commands.push("grestore".to_string());
553    }
554
555    /// Set RGB color helper
556    fn set_color(&mut self, color: Color) {
557        self.commands.push(format!(
558            "{:.4} {:.4} {:.4} RGB",
559            color.r as f64 / 255.0,
560            color.g as f64 / 255.0,
561            color.b as f64 / 255.0
562        ));
563    }
564
565    /// Convert to PostScript string
566    fn build_ps(&self) -> String {
567        let mut result = String::new();
568
569        for cmd in &self.commands {
570            result.push_str(cmd);
571            result.push('\n');
572        }
573
574        // Restore graphics state saved at page start
575        result.push_str("grestore\n");
576
577        result
578    }
579}
580
581impl std::fmt::Display for PsPage {
582    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583        write!(f, "{}", self.build_ps())
584    }
585}
586
587/// Select the PostScript font name for the Helvetica family based on weight/style.
588fn select_ps_font(family: &str, bold: bool, italic: bool) -> String {
589    // Normalise family name to its PS base name
590    let base = match family.to_lowercase().as_str() {
591        "times" | "times new roman" | "times-roman" => "Times",
592        "courier" | "courier new" => "Courier",
593        _ => "Helvetica",
594    };
595
596    match (base, bold, italic) {
597        ("Times", false, false) => "/Times-Roman".to_string(),
598        ("Times", true, false) => "/Times-Bold".to_string(),
599        ("Times", false, true) => "/Times-Italic".to_string(),
600        ("Times", true, true) => "/Times-BoldItalic".to_string(),
601        ("Courier", false, false) => "/Courier".to_string(),
602        ("Courier", true, false) => "/Courier-Bold".to_string(),
603        ("Courier", false, true) => "/Courier-Oblique".to_string(),
604        ("Courier", true, true) => "/Courier-BoldOblique".to_string(),
605        // Helvetica (default)
606        (_, false, false) => "/Helvetica".to_string(),
607        (_, true, false) => "/Helvetica-Bold".to_string(),
608        (_, false, true) => "/Helvetica-Oblique".to_string(),
609        (_, true, true) => "/Helvetica-BoldOblique".to_string(),
610    }
611}
612
613/// Render child areas recursively
614#[allow(clippy::too_many_arguments)]
615fn render_children(
616    area_tree: &AreaTree,
617    parent_id: AreaId,
618    ps_page: &mut PsPage,
619    offset_x: Length,
620    offset_y: Length,
621    image_map: &HashMap<AreaId, ImageData>,
622) -> Result<()> {
623    let children = area_tree.children(parent_id);
624
625    for child_id in children {
626        if let Some(child_node) = area_tree.get(child_id) {
627            // Calculate absolute position
628            let abs_x = offset_x + child_node.area.geometry.x;
629            let abs_y = offset_y + child_node.area.geometry.y;
630
631            // Check if clipping is needed
632            let needs_clipping = child_node
633                .area
634                .traits
635                .overflow
636                .map(|o| o.clips_content())
637                .unwrap_or(false);
638
639            if needs_clipping {
640                ps_page.save_clip_state(
641                    abs_x,
642                    abs_y,
643                    child_node.area.width(),
644                    child_node.area.height(),
645                );
646            }
647
648            // Render background color if present
649            if let Some(bg_color) = child_node.area.traits.background_color {
650                ps_page.add_background(
651                    abs_x,
652                    abs_y,
653                    child_node.area.width(),
654                    child_node.area.height(),
655                    bg_color,
656                );
657            }
658
659            // Render borders if present
660            if let (Some(border_widths), Some(border_colors), Some(border_styles)) = (
661                child_node.area.traits.border_width,
662                child_node.area.traits.border_color,
663                child_node.area.traits.border_style,
664            ) {
665                ps_page.add_borders(
666                    abs_x,
667                    abs_y,
668                    child_node.area.width(),
669                    child_node.area.height(),
670                    border_widths,
671                    border_colors,
672                    border_styles,
673                );
674            }
675
676            match child_node.area.area_type {
677                AreaType::Text => {
678                    // Check if this is a leader area
679                    if let Some(leader_pattern) = &child_node.area.traits.is_leader {
680                        render_leader(
681                            ps_page,
682                            leader_pattern,
683                            abs_x,
684                            abs_y,
685                            child_node.area.width(),
686                            child_node.area.height(),
687                            &child_node.area.traits,
688                        );
689                    } else if let Some(text_content) = child_node.area.text_content() {
690                        let font_size = child_node
691                            .area
692                            .traits
693                            .font_size
694                            .unwrap_or(Length::from_pt(12.0));
695
696                        // Determine bold/italic from traits
697                        let bold = child_node
698                            .area
699                            .traits
700                            .font_weight
701                            .map(|w| w >= 700)
702                            .unwrap_or(false);
703                        let italic = child_node
704                            .area
705                            .traits
706                            .font_style
707                            .map(|s| {
708                                matches!(
709                                    s,
710                                    fop_layout::area::FontStyle::Italic
711                                        | fop_layout::area::FontStyle::Oblique
712                                )
713                            })
714                            .unwrap_or(false);
715
716                        ps_page.add_text_styled(
717                            text_content,
718                            abs_x,
719                            abs_y,
720                            font_size,
721                            child_node.area.traits.color,
722                            bold,
723                            italic,
724                        );
725                    }
726                }
727                AreaType::Inline => {
728                    if let Some(leader_pattern) = &child_node.area.traits.is_leader {
729                        render_leader(
730                            ps_page,
731                            leader_pattern,
732                            abs_x,
733                            abs_y,
734                            child_node.area.width(),
735                            child_node.area.height(),
736                            &child_node.area.traits,
737                        );
738                    } else {
739                        render_children(area_tree, child_id, ps_page, abs_x, abs_y, image_map)?;
740                    }
741                }
742                AreaType::Viewport => {
743                    // Render image if available
744                    if let Some(img_data) = image_map.get(&child_id) {
745                        ps_page.add_image(
746                            img_data,
747                            abs_x,
748                            abs_y,
749                            child_node.area.width(),
750                            child_node.area.height(),
751                        );
752                    }
753                    // Still recurse for child areas
754                    render_children(area_tree, child_id, ps_page, abs_x, abs_y, image_map)?;
755                }
756                _ => {
757                    // Recursively render other areas
758                    render_children(area_tree, child_id, ps_page, abs_x, abs_y, image_map)?;
759                }
760            }
761
762            if needs_clipping {
763                ps_page.restore_clip_state();
764            }
765        }
766    }
767
768    Ok(())
769}
770
771/// Render a leader
772#[allow(clippy::too_many_arguments)]
773fn render_leader(
774    ps_page: &mut PsPage,
775    leader_pattern: &str,
776    x: Length,
777    y: Length,
778    width: Length,
779    height: Length,
780    traits: &fop_layout::area::TraitSet,
781) {
782    match leader_pattern {
783        "rule" => {
784            let thickness = traits.rule_thickness.unwrap_or(Length::from_pt(0.5));
785            let style = traits.rule_style.as_deref().unwrap_or("solid");
786            let color = traits.color.unwrap_or(Color::BLACK);
787
788            // Centre the rule vertically within the leader area
789            let half_diff = Length::from_millipoints((height - thickness).millipoints() / 2);
790            let rule_y = y + half_diff;
791
792            let half_thickness = Length::from_millipoints(thickness.millipoints() / 2);
793            ps_page.add_line(
794                x,
795                rule_y + half_thickness,
796                x + width,
797                rule_y + half_thickness,
798                color,
799                thickness,
800                style,
801            );
802        }
803        "dots" | "space" => {
804            // These are handled by text rendering or produce nothing
805        }
806        _ => {}
807    }
808}
809
810/// Escape PostScript special characters in strings
811fn escape_ps_string(text: &str) -> String {
812    text.replace('\\', "\\\\")
813        .replace('(', "\\(")
814        .replace(')', "\\)")
815        .replace('\r', "\\r")
816        .replace('\n', "\\n")
817        .replace('\t', "\\t")
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823    use fop_layout::area::BorderStyle;
824    use fop_types::{Color, Length};
825
826    // ── PsRenderer ───────────────────────────────────────────────────────────
827
828    #[test]
829    fn test_ps_renderer_new() {
830        let renderer = PsRenderer::new();
831        let _ = renderer;
832    }
833
834    #[test]
835    fn test_ps_renderer_default() {
836        let _r1 = PsRenderer::new();
837        let _r2 = PsRenderer::default();
838    }
839
840    // ── PsDocument ───────────────────────────────────────────────────────────
841
842    #[test]
843    fn test_ps_document_empty_has_ps_header() {
844        let doc = PsDocument::new();
845        let output = doc.to_string();
846        assert!(
847            output.starts_with("%!PS-Adobe-3.0"),
848            "PostScript document must begin with %!PS-Adobe-3.0"
849        );
850    }
851
852    #[test]
853    fn test_ps_document_empty_has_trailer() {
854        let doc = PsDocument::new();
855        let output = doc.to_string();
856        assert!(
857            output.contains("%%Trailer"),
858            "PS document must have %%Trailer"
859        );
860        assert!(output.contains("%%EOF"), "PS document must end with %%EOF");
861    }
862
863    #[test]
864    fn test_ps_document_default_equals_new() {
865        let d1 = PsDocument::new().to_string();
866        let d2 = PsDocument::default().to_string();
867        assert_eq!(d1, d2, "default() and new() must produce identical output");
868    }
869
870    #[test]
871    fn test_ps_document_empty_page_count() {
872        let doc = PsDocument::new();
873        let output = doc.to_string();
874        assert!(
875            output.contains("%%Pages: 0"),
876            "empty document must declare 0 pages"
877        );
878    }
879
880    #[test]
881    fn test_ps_document_add_page_increments_count() {
882        let mut doc = PsDocument::new();
883        doc.add_page(PsPage::new(Length::from_mm(210.0), Length::from_mm(297.0)));
884        let output = doc.to_string();
885        // %%Pages appears both in header and trailer
886        assert!(
887            output.contains("%%Pages: 1"),
888            "single-page doc must declare 1 page"
889        );
890    }
891
892    #[test]
893    fn test_ps_document_two_pages() {
894        let mut doc = PsDocument::new();
895        doc.add_page(PsPage::new(Length::from_mm(210.0), Length::from_mm(297.0)));
896        doc.add_page(PsPage::new(Length::from_mm(210.0), Length::from_mm(297.0)));
897        let output = doc.to_string();
898        assert!(
899            output.contains("%%Pages: 2"),
900            "two-page doc must declare 2 pages"
901        );
902        assert!(
903            output.contains("%%Page: 1 2"),
904            "first page label must be '1 2'"
905        );
906        assert!(
907            output.contains("%%Page: 2 2"),
908            "second page label must be '2 2'"
909        );
910    }
911
912    #[test]
913    fn test_ps_document_prolog_contains_helpers() {
914        let doc = PsDocument::new();
915        let output = doc.to_string();
916        assert!(
917            output.contains("/frect"),
918            "prolog must define /frect helper"
919        );
920        assert!(output.contains("/FN"), "prolog must define /FN font helper");
921        assert!(output.contains("/SH"), "prolog must define /SH show helper");
922    }
923
924    #[test]
925    fn test_ps_document_page_has_showpage() {
926        let mut doc = PsDocument::new();
927        doc.add_page(PsPage::new(Length::from_mm(210.0), Length::from_mm(297.0)));
928        let output = doc.to_string();
929        assert!(
930            output.contains("showpage"),
931            "each page must end with showpage"
932        );
933    }
934
935    // ── PsPage ───────────────────────────────────────────────────────────────
936
937    fn make_page() -> PsPage {
938        PsPage::new(Length::from_mm(210.0), Length::from_mm(297.0))
939    }
940
941    #[test]
942    fn test_ps_page_new_contains_gsave() {
943        let page = make_page();
944        let output = page.to_string();
945        assert!(output.contains("gsave"), "new page must begin with gsave");
946    }
947
948    #[test]
949    fn test_ps_page_add_background() {
950        let mut page = make_page();
951        page.add_background(
952            Length::from_pt(10.0),
953            Length::from_pt(20.0),
954            Length::from_pt(100.0),
955            Length::from_pt(50.0),
956            Color::RED,
957        );
958        let output = page.to_string();
959        assert!(output.contains("frect"), "background must use frect");
960        // RGB values for red (1 0 0)
961        assert!(output.contains("RGB"), "background must set RGB color");
962    }
963
964    #[test]
965    fn test_ps_page_add_text_basic() {
966        let mut page = make_page();
967        page.add_text(
968            "Hello PS",
969            Length::from_pt(72.0),
970            Length::from_pt(720.0),
971            Length::from_pt(12.0),
972            None,
973        );
974        let output = page.to_string();
975        assert!(
976            output.contains("Hello PS"),
977            "text content must appear in output"
978        );
979        assert!(output.contains("SH"), "text must end with SH (show)");
980        assert!(output.contains("FN"), "text must select a font with FN");
981    }
982
983    #[test]
984    fn test_ps_page_add_text_styled_bold() {
985        let mut page = make_page();
986        page.add_text_styled(
987            "Bold Text",
988            Length::from_pt(50.0),
989            Length::from_pt(700.0),
990            Length::from_pt(14.0),
991            Some(Color::BLACK),
992            true,
993            false,
994        );
995        let output = page.to_string();
996        assert!(
997            output.contains("Bold Text"),
998            "bold text content must appear"
999        );
1000        assert!(
1001            output.contains("Helvetica-Bold"),
1002            "bold must select Helvetica-Bold"
1003        );
1004    }
1005
1006    #[test]
1007    fn test_ps_page_add_text_styled_italic() {
1008        let mut page = make_page();
1009        page.add_text_styled(
1010            "Italic",
1011            Length::from_pt(50.0),
1012            Length::from_pt(700.0),
1013            Length::from_pt(12.0),
1014            None,
1015            false,
1016            true,
1017        );
1018        let output = page.to_string();
1019        assert!(
1020            output.contains("Helvetica-Oblique"),
1021            "italic must select Helvetica-Oblique"
1022        );
1023    }
1024
1025    #[test]
1026    fn test_ps_page_add_text_styled_bold_italic() {
1027        let mut page = make_page();
1028        page.add_text_styled(
1029            "BI",
1030            Length::from_pt(10.0),
1031            Length::from_pt(10.0),
1032            Length::from_pt(10.0),
1033            None,
1034            true,
1035            true,
1036        );
1037        let output = page.to_string();
1038        assert!(
1039            output.contains("Helvetica-BoldOblique"),
1040            "bold+italic must select Helvetica-BoldOblique"
1041        );
1042    }
1043
1044    #[test]
1045    fn test_ps_page_add_line() {
1046        let mut page = make_page();
1047        page.add_line(
1048            Length::from_pt(0.0),
1049            Length::from_pt(0.0),
1050            Length::from_pt(200.0),
1051            Length::from_pt(0.0),
1052            Color::BLACK,
1053            Length::from_pt(1.0),
1054            "solid",
1055        );
1056        let output = page.to_string();
1057        assert!(output.contains("M"), "line must have moveto (M)");
1058        assert!(
1059            output.contains(
1060                " L
1061"
1062            ) && output.contains(
1063                "
1064S
1065"
1066            ),
1067            "line must have separate L and S commands"
1068        );
1069        assert!(output.contains("LW"), "line must set line width (LW)");
1070    }
1071
1072    #[test]
1073    fn test_ps_page_add_borders_top_only() {
1074        let mut page = make_page();
1075        page.add_borders(
1076            Length::from_pt(10.0),
1077            Length::from_pt(10.0),
1078            Length::from_pt(100.0),
1079            Length::from_pt(50.0),
1080            [
1081                Length::from_pt(2.0), // top
1082                Length::ZERO,         // right
1083                Length::ZERO,         // bottom
1084                Length::ZERO,         // left
1085            ],
1086            [Color::BLACK; 4],
1087            [BorderStyle::Solid; 4],
1088        );
1089        let output = page.to_string();
1090        assert!(
1091            output.contains("frect"),
1092            "borders must use frect for filled rectangles"
1093        );
1094    }
1095
1096    #[test]
1097    fn test_ps_page_add_borders_none_style_skipped() {
1098        let mut page = make_page();
1099        let initial_len = page.commands.len();
1100        page.add_borders(
1101            Length::from_pt(0.0),
1102            Length::from_pt(0.0),
1103            Length::from_pt(100.0),
1104            Length::from_pt(50.0),
1105            [Length::from_pt(1.0); 4],
1106            [Color::BLACK; 4],
1107            [BorderStyle::None; 4], // all none — should not draw anything
1108        );
1109        // No new frect commands should have been added
1110        assert_eq!(
1111            page.commands.len(),
1112            initial_len,
1113            "BorderStyle::None must not produce drawing commands"
1114        );
1115    }
1116
1117    #[test]
1118    fn test_ps_page_save_restore_clip() {
1119        let mut page = make_page();
1120        page.save_clip_state(
1121            Length::from_pt(10.0),
1122            Length::from_pt(10.0),
1123            Length::from_pt(80.0),
1124            Length::from_pt(60.0),
1125        );
1126        page.restore_clip_state();
1127        let output = page.to_string();
1128        // save_clip_state uses gsave + clip; restore uses grestore
1129        assert!(
1130            output.contains("gsave"),
1131            "clip state save must include gsave"
1132        );
1133        assert!(
1134            output.contains("grestore"),
1135            "clip state restore must include grestore"
1136        );
1137    }
1138
1139    // ── select_ps_font helper (tested indirectly via add_text_styled) ─────────
1140
1141    #[test]
1142    fn test_ps_font_selection_times() {
1143        let mut page = make_page();
1144        // Directly call the public add_text_styled with Times via a modified
1145        // wrapper isn't possible (select_ps_font is private), but we can
1146        // verify the Helvetica family path is exhaustive via the styled API.
1147        page.add_text_styled(
1148            "plain",
1149            Length::from_pt(10.0),
1150            Length::from_pt(10.0),
1151            Length::from_pt(10.0),
1152            None,
1153            false,
1154            false,
1155        );
1156        let output = page.to_string();
1157        assert!(
1158            output.contains("Helvetica"),
1159            "plain text must use Helvetica"
1160        );
1161    }
1162
1163    // ── PS output structural checks ───────────────────────────────────────────
1164
1165    #[test]
1166    fn test_ps_document_has_bounding_box_with_pages() {
1167        let mut doc = PsDocument::new();
1168        doc.add_page(PsPage::new(Length::from_mm(210.0), Length::from_mm(297.0)));
1169        let output = doc.to_string();
1170        assert!(
1171            output.contains("%%BoundingBox:"),
1172            "document with pages must have %%BoundingBox"
1173        );
1174    }
1175
1176    #[test]
1177    fn test_ps_document_language_level_2() {
1178        let doc = PsDocument::new();
1179        let output = doc.to_string();
1180        assert!(
1181            output.contains("%%LanguageLevel: 2"),
1182            "must declare PostScript language level 2"
1183        );
1184    }
1185
1186    #[test]
1187    fn test_ps_document_creator_comment() {
1188        let doc = PsDocument::new();
1189        let output = doc.to_string();
1190        assert!(
1191            output.contains("%%Creator: Apache FOP Rust"),
1192            "must include Creator DSC comment"
1193        );
1194    }
1195
1196    #[test]
1197    fn test_ps_page_output_ends_with_grestore() {
1198        let page = make_page();
1199        let output = page.to_string();
1200        // The page build_ps method ends with grestore
1201        assert!(
1202            output.trim_end().ends_with("grestore"),
1203            "page PS must end with grestore"
1204        );
1205    }
1206}