c2pdf/
code_to_pdf.rs

1//! Contains [`HighlighterConfig`] and [`CodeToPdf`] structs
2
3use std::{ffi::OsStr, fs, io::BufRead, path::Path};
4
5use ignore::Walk;
6use printpdf::{
7    FontId, Op, PdfDocument, PdfPage, Px, RawImage, TextItem, XObjectRotation, XObjectTransform,
8    color,
9};
10use syntect::{
11    easy::HighlightFile,
12    highlighting::{Color, Style, Theme},
13    parsing::SyntaxSet,
14};
15
16use crate::{dimensions::Dimensions, helpers::init_page, text_manipulation::TextWrapper};
17
18/// Configuration struct for the highlighter ([`syntect`])
19///
20/// Contains the desired theme, syntax set, and the maximum line length to highlight
21pub struct HighlighterConfig {
22    syntax_set: SyntaxSet,
23    theme: Theme,
24    max_line_len_to_highlight: usize,
25}
26impl HighlighterConfig {
27    /// Initialises new [`HighlighterConfig`]
28    pub fn new(syntax_set: SyntaxSet, theme: Theme) -> Self {
29        Self {
30            syntax_set,
31            theme,
32            max_line_len_to_highlight: 20_000,
33        }
34    }
35}
36
37/// Main struct for generating PDFs.
38/// It handles almost the entire process of reading and highlighting code,
39/// as well as actually writing it to the PDF
40pub struct CodeToPdf {
41    current_page_contents: Vec<Op>,
42    doc: PdfDocument,
43    font_id: FontId,
44    page_dimensions: Dimensions,
45    text_wrapper: TextWrapper,
46    processed_file_count: usize,
47}
48impl CodeToPdf {
49    /// Initialises a new [`CodeToPdf`]
50    pub fn new(
51        doc: PdfDocument,
52        font_id: FontId,
53        page_dimensions: Dimensions,
54        text_wrapper: TextWrapper,
55    ) -> Self {
56        Self {
57            current_page_contents: vec![],
58            doc,
59            font_id,
60            page_dimensions,
61            text_wrapper,
62            processed_file_count: 0,
63        }
64    }
65    /// Saves the current page contents to the document, and clears [`CodeToPdf::current_page_contents`]
66    fn save_page(&mut self) {
67        let contents = std::mem::take(&mut self.current_page_contents);
68        let page = PdfPage::new(
69            self.page_dimensions.width,
70            self.page_dimensions.height,
71            contents,
72        );
73        self.doc.pages.push(page);
74    }
75
76    /// Initialises [`CodeToPdf::current_page_contents`] with basic contents
77    fn init_page(&mut self, path: &Path) {
78        // Should never be called on a non-empty current_pages_contents, so check it in debug mode
79        debug_assert_eq!(self.current_page_contents.len(), 0);
80
81        init_page(
82            &mut self.current_page_contents,
83            &self.page_dimensions,
84            self.font_id.clone(),
85            self.text_wrapper.font_size(),
86            path,
87            &mut self.text_wrapper,
88        );
89    }
90    /// Computes maximum number of lines that can be displayed on a page
91    fn max_line_count(&self) -> u32 {
92        let max_height = self.page_dimensions.max_text_height();
93        ((max_height).into_pt().0 / (self.text_wrapper.font_size() * 1.2)).floor() as u32
94    }
95    /// Increment given line_count. Begin a new page if it's too high
96    /// Returns `true` if a new page is created
97    fn increment_line_count(&mut self, line_count: &mut u32, path: &Path) -> bool {
98        *line_count += 1;
99        if *line_count > self.max_line_count() {
100            self.save_page();
101            self.init_page(path);
102            *line_count = 0;
103            true
104        } else {
105            false
106        }
107    }
108    /// Generates all the pages for a file
109    fn generate_highlighted_pages(
110        &mut self,
111        highlighter: &mut HighlightFile,
112        path: &Path,
113        highlighter_config: &HighlighterConfig,
114    ) {
115        let mut line = String::new();
116        let mut line_count = 0;
117        self.init_page(path);
118        let mut has_added_text = false;
119        while highlighter.reader.read_line(&mut line).unwrap_or(0) > 0 {
120            has_added_text = true;
121            // Store the char count for the current line
122            let mut line_width = 0.0;
123            let regions: Vec<(Style, &str)> =
124                if line.len() < highlighter_config.max_line_len_to_highlight {
125                    highlighter
126                        .highlight_lines
127                        .highlight_line(&line, &highlighter_config.syntax_set)
128                        .unwrap()
129                } else {
130                    vec![(
131                        Style {
132                            foreground: Color::BLACK,
133                            background: Color::WHITE,
134                            font_style: syntect::highlighting::FontStyle::default(),
135                        },
136                        &line,
137                    )]
138                };
139            for (style, text) in regions {
140                line_width += self.text_wrapper.get_width(text);
141                // If current line is getting too long, add a line break
142                if line_width > self.page_dimensions.max_text_width().into_pt().0 {
143                    self.increment_line_count(&mut line_count, path);
144                    self.current_page_contents.push(Op::AddLineBreak);
145                    line_width = 0.0;
146                }
147                let text_style = style.foreground;
148                // Set PDF text colour
149                self.current_page_contents.push(Op::SetFillColor {
150                    col: color::Color::Rgb(color::Rgb {
151                        r: (text_style.r as f32) / 255.0,
152                        g: (text_style.g as f32) / 255.0,
153                        b: (text_style.b as f32) / 255.0,
154                        icc_profile: None,
155                    }),
156                });
157                let lines = self
158                    .text_wrapper
159                    .split_into_lines(text, self.page_dimensions.max_text_width());
160                // If only a single line, then no new lines are going to be made (as we're processing a region here)
161                match lines.len() {
162                    1 => {
163                        self.current_page_contents.push(Op::WriteText {
164                            items: vec![TextItem::Text(text.to_owned())],
165                            font: self.font_id.clone(),
166                        });
167                    }
168                    // If the region is too long to fit onto a new line, split and write to multiple different lines
169                    _ => {
170                        let mut first = true;
171                        for l in lines {
172                            if !first {
173                                self.current_page_contents.push(Op::AddLineBreak);
174                            }
175                            first = false;
176                            self.current_page_contents.push(Op::WriteText {
177                                items: vec![TextItem::Text(l)],
178                                font: self.font_id.clone(),
179                            });
180                            self.increment_line_count(&mut line_count, path);
181                        }
182                    }
183                }
184            }
185
186            // Split text into chunks the maximum width of the view
187
188            if !self.increment_line_count(&mut line_count, path) {
189                self.current_page_contents.push(Op::AddLineBreak);
190            }
191            line.clear();
192        }
193        // Clear page if no text has been added to it
194        if has_added_text {
195            self.save_page();
196        } else {
197            self.current_page_contents.clear()
198        }
199    }
200
201    /// Generates a page containing the image at the path given
202    fn generate_image_page(&mut self, path: &Path) {
203        let bytes = if let Ok(b) = fs::read(path) {
204            b
205        } else {
206            return;
207        };
208        let image = if let Ok(img) = RawImage::decode_from_bytes(&bytes, &mut vec![]) {
209            img
210        } else {
211            return;
212        };
213        self.init_page(path);
214        let image_id = self.doc.add_image(&image);
215        let pg_x_dpi = self.page_dimensions.width.into_pt().into_px(300.0).0;
216        let pg_y_dpi = self.page_dimensions.height.into_pt().into_px(300.0).0;
217
218        let x_scaling = pg_x_dpi as f32 / image.width as f32;
219        let y_scaling = pg_y_dpi as f32 / image.height as f32;
220
221        let scale = f32::min(x_scaling, y_scaling);
222        // If width is significantly bigger than the height, rotate so it's oriented to fill more of the page
223        let rotation = if image.width > (image.height as f32 * 1.25) as usize {
224            Some(XObjectRotation {
225                angle_ccw_degrees: -90.0,
226                rotation_center_x: Px(((image.width as f32 * scale) / 2.0) as usize),
227                rotation_center_y: Px(((image.height as f32 * scale) / 2.0) as usize),
228            })
229        } else {
230            None
231        };
232        self.current_page_contents.push(Op::UseXobject {
233            id: image_id.clone(),
234            transform: XObjectTransform {
235                scale_x: Some(scale),
236                scale_y: Some(scale),
237                rotate: rotation,
238                ..Default::default()
239            },
240        });
241        self.save_page();
242    }
243    /// Generates pages for a file
244    pub fn process_file(
245        &mut self,
246        file: &Path,
247        highlighter_config: &HighlighterConfig,
248    ) -> Result<(), Box<dyn std::error::Error>> {
249        println!("Generating pages for {}", file.display());
250        self.processed_file_count += 1;
251        match file.extension().and_then(OsStr::to_str) {
252            Some("jpg" | "jpeg" | "png" | "ico" | "bmp" | "webp") => {
253                self.generate_image_page(file);
254                Ok(())
255            }
256            _ => {
257                let mut highlighter = HighlightFile::new(
258                    file,
259                    &highlighter_config.syntax_set,
260                    &highlighter_config.theme,
261                )?;
262
263                self.generate_highlighted_pages(&mut highlighter, file, highlighter_config);
264
265                Ok(())
266            }
267        }
268    }
269    /// Consumes entire walker
270    pub fn process_files(&mut self, walker: Walk, highlighter_config: HighlighterConfig) {
271        dbg!(self.max_line_count());
272        for result in walker {
273            match result {
274                Ok(entry) => {
275                    if entry.file_type().is_some_and(|f| f.is_file()) {
276                        if let Err(err) = self.process_file(entry.path(), &highlighter_config) {
277                            println!("ERROR: {}", err)
278                        }
279                    }
280                }
281                Err(err) => println!("ERROR: {}", err),
282            }
283        }
284    }
285
286    /// Consumes the instance and returns the underlying document
287    pub fn document(self) -> PdfDocument {
288        self.doc
289    }
290
291    /// Returns number of files processed by [`CodeToPdf::process_files`]
292    pub fn processed_file_count(&self) -> usize {
293        self.processed_file_count
294    }
295}