1use crate::image::ImageInfo;
6use fop_layout::{AreaId, AreaTree, AreaType};
7use fop_types::{Color, Length, Result};
8use std::collections::HashMap;
9
10pub struct PsRenderer {
12 #[allow(dead_code)]
14 page_width: Length,
15
16 #[allow(dead_code)]
18 page_height: Length,
19}
20
21impl PsRenderer {
22 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 pub fn render_to_ps(&self, area_tree: &AreaTree) -> Result<String> {
32 let mut ps_doc = PsDocument::new();
33
34 let mut image_map = HashMap::new();
36 self.collect_images(area_tree, &mut image_map)?;
37
38 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 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 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_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#[derive(Clone)]
109struct ImageData {
110 width: u32,
111 height: u32,
112 #[allow(dead_code)]
113 data: Vec<u8>,
114}
115
116pub struct PsDocument {
118 pages: Vec<PsPage>,
119}
120
121impl PsDocument {
122 pub fn new() -> Self {
124 Self { pages: Vec::new() }
125 }
126
127 pub fn add_page(&mut self, page: PsPage) {
129 self.pages.push(page);
130 }
131
132 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 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 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 fn write_prolog(&self, result: &mut String) {
171 result.push_str("%%BeginProlog\n");
172
173 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 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 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 fn write_setup(&self, result: &mut String) {
222 result.push_str("%%BeginSetup\n");
223
224 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 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 fn build_ps(&self) -> String {
265 let mut result = String::new();
266 let total = self.pages.len();
267
268 self.write_dsc_header(&mut result);
270
271 self.write_prolog(&mut result);
273
274 self.write_setup(&mut result);
276
277 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 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
306pub struct PsPage {
308 width: Length,
309 height: Length,
310 commands: Vec<String>,
311}
312
313impl PsPage {
314 pub fn new(width: Length, height: Length) -> Self {
316 let commands = vec!["gsave".to_string()];
318
319 Self {
320 width,
321 height,
322 commands,
323 }
324 }
325
326 #[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 self.set_color(color);
338
339 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 #[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 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 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 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 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 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 let c = color.unwrap_or(Color::BLACK);
430 self.set_color(c);
431
432 let font_name = "/Helvetica";
434 self.commands
435 .push(format!("{} {:.3} FN", font_name, font_size.to_pt()));
436
437 let escaped_text = escape_ps_string(text);
439
440 self.commands.push(format!(
442 "{:.3} {:.3} M ({}) SH",
443 x.to_pt(),
444 y.to_pt(),
445 escaped_text
446 ));
447 }
448
449 #[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 #[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 #[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 self.commands.push("gsave".to_string());
511
512 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 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 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 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 pub fn restore_clip_state(&mut self) {
552 self.commands.push("grestore".to_string());
553 }
554
555 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 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 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
587fn select_ps_font(family: &str, bold: bool, italic: bool) -> String {
589 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 (_, 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#[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 let abs_x = offset_x + child_node.area.geometry.x;
629 let abs_y = offset_y + child_node.area.geometry.y;
630
631 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 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 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 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 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 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 render_children(area_tree, child_id, ps_page, abs_x, abs_y, image_map)?;
755 }
756 _ => {
757 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#[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 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 }
806 _ => {}
807 }
808}
809
810fn 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 #[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 #[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 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 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 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), Length::ZERO, Length::ZERO, Length::ZERO, ],
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], );
1109 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 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 #[test]
1142 fn test_ps_font_selection_times() {
1143 let mut page = make_page();
1144 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 #[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 assert!(
1202 output.trim_end().ends_with("grestore"),
1203 "page PS must end with grestore"
1204 );
1205 }
1206}