Skip to main content

plotkit_render_pdf/
lib.rs

1//! PDF rendering backend for plotkit.
2//!
3//! Produces PDF output by translating plotkit primitives into PDF drawing
4//! operations using the `printpdf` crate. Text is rendered with built-in
5//! PDF fonts (Helvetica family).
6
7#![deny(missing_docs)]
8
9use plotkit_core::primitives::{
10    Affine, Color, DashPattern, FontWeight, HAlign, Image, Paint, Path, PathEl, Point, Rect,
11    Stroke, StrokeCap, StrokeJoin, TextStyle, VAlign,
12};
13use plotkit_core::renderer::Renderer;
14
15use printpdf::path::{PaintMode, WindingOrder};
16use printpdf::{
17    BuiltinFont, IndirectFontRef, LineCapStyle, LineDashPattern, LineJoinStyle, Mm, PdfDocument,
18    PdfDocumentReference, PdfLayerIndex, PdfLayerReference, PdfPageIndex, Polygon, Rgb,
19};
20
21/// Pixels-to-millimeters conversion factor at 72 DPI.
22///
23/// 1 inch = 25.4 mm, 1 inch = 72 pixels => 1 pixel = 25.4 / 72 mm.
24const PX_TO_MM: f64 = 25.4 / 72.0;
25
26/// Converts a pixel value to printpdf `Mm`.
27#[inline]
28fn px_to_mm(px: f64) -> Mm {
29    Mm((px * PX_TO_MM) as f32)
30}
31
32/// A renderer that produces PDF output.
33///
34/// Uses printpdf's `PdfDocument` / `PdfPage` / `PdfLayer` model.
35/// All plotkit coordinates (origin top-left, y-down) are converted to PDF
36/// coordinates (origin bottom-left, y-up) during rendering.
37pub struct PdfRenderer {
38    width: u32,
39    height: u32,
40    doc: PdfDocumentReference,
41    page_idx: PdfPageIndex,
42    layer_idx: PdfLayerIndex,
43    /// Stack depth for save/restore graphics state (clip simulation).
44    clip_depth: usize,
45}
46
47impl PdfRenderer {
48    /// Creates a new PDF renderer with the given dimensions in pixels.
49    ///
50    /// A single PDF page is created with dimensions matching the pixel size
51    /// at 72 DPI.
52    pub fn new(width: u32, height: u32) -> Self {
53        let w_mm = px_to_mm(width as f64);
54        let h_mm = px_to_mm(height as f64);
55
56        let (doc, page_idx, layer_idx) = PdfDocument::new("plotkit", w_mm, h_mm, "Layer 1");
57
58        Self {
59            width,
60            height,
61            doc,
62            page_idx,
63            layer_idx,
64            clip_depth: 0,
65        }
66    }
67
68    /// Returns a reference to the current PDF layer for drawing operations.
69    fn current_layer(&self) -> PdfLayerReference {
70        let page = self.doc.get_page(self.page_idx);
71        page.get_layer(self.layer_idx)
72    }
73
74    /// Converts a plotkit y-coordinate (top-left origin, y-down) to PDF
75    /// y-coordinate (bottom-left origin, y-up).
76    #[inline]
77    fn flip_y(&self, y: f64) -> f64 {
78        self.height as f64 - y
79    }
80
81    /// Applies the given affine transform to a point and flips y for PDF.
82    #[inline]
83    fn transform_point(&self, p: Point, transform: Affine) -> (Mm, Mm) {
84        let coeffs = transform.as_coeffs();
85        let tx = coeffs[0] * p.x + coeffs[2] * p.y + coeffs[4];
86        let ty = coeffs[1] * p.x + coeffs[3] * p.y + coeffs[5];
87        (px_to_mm(tx), px_to_mm(self.flip_y(ty)))
88    }
89
90    /// Converts a plotkit `Path` into a vector of printpdf polygon rings,
91    /// applying the transform and y-flip.
92    ///
93    /// Each sub-path becomes a separate ring (a `Vec<(printpdf::Point, bool)>`).
94    /// The `bool` flag marks bezier control points according to printpdf's
95    /// convention: two consecutive `true` flags on adjacent points signal
96    /// that the next three points form a cubic bezier curve.
97    fn convert_path_to_rings(
98        &self,
99        path: &Path,
100        transform: Affine,
101    ) -> Vec<Vec<(printpdf::Point, bool)>> {
102        let mut rings: Vec<Vec<(printpdf::Point, bool)>> = Vec::new();
103        let mut current_ring: Vec<(printpdf::Point, bool)> = Vec::new();
104
105        for el in &path.elements {
106            match *el {
107                PathEl::MoveTo(p) => {
108                    if !current_ring.is_empty() {
109                        rings.push(std::mem::take(&mut current_ring));
110                    }
111                    let (mx, my) = self.transform_point(p, transform);
112                    current_ring.push((printpdf::Point::new(mx, my), false));
113                }
114                PathEl::LineTo(p) => {
115                    let (lx, ly) = self.transform_point(p, transform);
116                    current_ring.push((printpdf::Point::new(lx, ly), false));
117                }
118                PathEl::QuadTo(ctrl, end) => {
119                    // Elevate quadratic to cubic bezier.
120                    if let Some(last) = current_ring.last_mut() {
121                        let p0x = last.0.x.0;
122                        let p0y = last.0.y.0;
123                        // Mark previous point to start bezier sequence.
124                        last.1 = true;
125
126                        let (cx_mm, cy_mm) = self.transform_point(ctrl, transform);
127                        let (ex_mm, ey_mm) = self.transform_point(end, transform);
128
129                        // Cubic control points from quadratic:
130                        // CP1 = P0 + 2/3 * (Q - P0)
131                        // CP2 = P  + 2/3 * (Q - P)
132                        let cp1x = p0x + 2.0 / 3.0 * (cx_mm.0 - p0x);
133                        let cp1y = p0y + 2.0 / 3.0 * (cy_mm.0 - p0y);
134                        let cp2x = ex_mm.0 + 2.0 / 3.0 * (cx_mm.0 - ex_mm.0);
135                        let cp2y = ey_mm.0 + 2.0 / 3.0 * (cy_mm.0 - ey_mm.0);
136
137                        current_ring
138                            .push((printpdf::Point::new(Mm(cp1x), Mm(cp1y)), true));
139                        current_ring
140                            .push((printpdf::Point::new(Mm(cp2x), Mm(cp2y)), false));
141                        current_ring.push((printpdf::Point::new(ex_mm, ey_mm), false));
142                    }
143                }
144                PathEl::CurveTo(c1, c2, end) => {
145                    // Mark previous point to start bezier sequence.
146                    if let Some(last) = current_ring.last_mut() {
147                        last.1 = true;
148                    }
149                    let (c1x, c1y) = self.transform_point(c1, transform);
150                    let (c2x, c2y) = self.transform_point(c2, transform);
151                    let (ex, ey) = self.transform_point(end, transform);
152
153                    current_ring.push((printpdf::Point::new(c1x, c1y), true));
154                    current_ring.push((printpdf::Point::new(c2x, c2y), false));
155                    current_ring.push((printpdf::Point::new(ex, ey), false));
156                }
157                PathEl::ClosePath => {
158                    // Close the sub-path by pushing the ring.
159                    if !current_ring.is_empty() {
160                        rings.push(std::mem::take(&mut current_ring));
161                    }
162                }
163            }
164        }
165
166        if !current_ring.is_empty() {
167            rings.push(current_ring);
168        }
169
170        rings
171    }
172
173    /// Builds a printpdf `Color` from a plotkit `Color` (RGB only).
174    ///
175    /// Alpha is not directly supported by PDF color operators; semi-transparent
176    /// drawing would require an extended graphics state, which is not yet
177    /// exposed by printpdf's public API for arbitrary alpha values.
178    fn convert_color(c: &Color) -> printpdf::Color {
179        printpdf::Color::Rgb(Rgb::new(
180            c.r as f32 / 255.0,
181            c.g as f32 / 255.0,
182            c.b as f32 / 255.0,
183            None,
184        ))
185    }
186
187    /// Returns the built-in PDF font matching the requested style.
188    fn builtin_font(&self, style: &TextStyle) -> IndirectFontRef {
189        let font_name = match style.weight {
190            FontWeight::Bold => BuiltinFont::HelveticaBold,
191            FontWeight::Normal => BuiltinFont::Helvetica,
192        };
193        self.doc
194            .add_builtin_font(font_name)
195            .expect("built-in font")
196    }
197
198    /// Converts a plotkit `DashPattern` to a printpdf `LineDashPattern`.
199    fn convert_dash(dp: &DashPattern) -> LineDashPattern {
200        let dashes_mm: Vec<i64> = dp
201            .dashes
202            .iter()
203            .map(|&d| (d * PX_TO_MM * 1000.0) as i64)
204            .collect();
205        let offset = (dp.offset * PX_TO_MM * 1000.0) as i64;
206        match dashes_mm.len() {
207            0 => LineDashPattern::default(),
208            1 => LineDashPattern {
209                dash_1: Some(dashes_mm[0]),
210                gap_1: Some(dashes_mm[0]),
211                offset,
212                ..Default::default()
213            },
214            2 => LineDashPattern {
215                dash_1: Some(dashes_mm[0]),
216                gap_1: Some(dashes_mm[1]),
217                offset,
218                ..Default::default()
219            },
220            3 => LineDashPattern {
221                dash_1: Some(dashes_mm[0]),
222                gap_1: Some(dashes_mm[1]),
223                dash_2: Some(dashes_mm[2]),
224                offset,
225                ..Default::default()
226            },
227            _ => LineDashPattern {
228                dash_1: Some(dashes_mm[0]),
229                gap_1: Some(dashes_mm[1]),
230                dash_2: Some(dashes_mm[2]),
231                gap_2: Some(dashes_mm[3]),
232                offset,
233                ..Default::default()
234            },
235        }
236    }
237}
238
239impl Renderer for PdfRenderer {
240    fn size(&self) -> (u32, u32) {
241        (self.width, self.height)
242    }
243
244    fn fill_path(&mut self, path: &Path, paint: &Paint, transform: Affine) {
245        let rings = self.convert_path_to_rings(path, transform);
246        if rings.is_empty() {
247            return;
248        }
249
250        let layer = self.current_layer();
251        let fill_color = Self::convert_color(&paint.color);
252        layer.set_fill_color(fill_color);
253
254        let poly = Polygon {
255            rings,
256            mode: PaintMode::Fill,
257            winding_order: WindingOrder::NonZero,
258        };
259        layer.add_polygon(poly);
260    }
261
262    fn stroke_path(
263        &mut self,
264        path: &Path,
265        paint: &Paint,
266        stroke: &Stroke,
267        transform: Affine,
268    ) {
269        let rings = self.convert_path_to_rings(path, transform);
270        if rings.is_empty() {
271            return;
272        }
273
274        let layer = self.current_layer();
275        let stroke_color = Self::convert_color(&paint.color);
276        let width_mm = (stroke.width * PX_TO_MM) as f32;
277
278        let line_cap = match stroke.cap {
279            StrokeCap::Butt => LineCapStyle::Butt,
280            StrokeCap::Round => LineCapStyle::Round,
281            StrokeCap::Square => LineCapStyle::ProjectingSquare,
282        };
283
284        let line_join = match stroke.join {
285            StrokeJoin::Miter => LineJoinStyle::Miter,
286            StrokeJoin::Round => LineJoinStyle::Round,
287            StrokeJoin::Bevel => LineJoinStyle::Limit,
288        };
289
290        let dash_pattern = match stroke.dash {
291            Some(ref dp) => Self::convert_dash(dp),
292            None => LineDashPattern::default(),
293        };
294
295        layer.set_outline_color(stroke_color);
296        layer.set_outline_thickness(width_mm);
297        layer.set_line_cap_style(line_cap);
298        layer.set_line_join_style(line_join);
299        layer.set_line_dash_pattern(dash_pattern);
300
301        let poly = Polygon {
302            rings,
303            mode: PaintMode::Stroke,
304            winding_order: WindingOrder::NonZero,
305        };
306        layer.add_polygon(poly);
307    }
308
309    fn draw_text(&mut self, text: &str, pos: Point, style: &TextStyle, transform: Affine) {
310        if text.is_empty() {
311            return;
312        }
313
314        let font = self.builtin_font(style);
315        let font_size_pt = style.size;
316
317        // Measure text for alignment.
318        let (text_w, text_h) = self.measure_text(text, style);
319
320        // Compute adjusted position based on alignment.
321        let adjusted_x = match style.halign {
322            HAlign::Left => pos.x,
323            HAlign::Center => pos.x - text_w / 2.0,
324            HAlign::Right => pos.x - text_w,
325        };
326
327        // PDF text is positioned at the baseline.
328        // Approximate ascent as ~0.75 * font_size, descent as ~0.25 * font_size.
329        let ascent = style.size * 0.75;
330        let adjusted_y = match style.valign {
331            VAlign::Top => pos.y + ascent,
332            VAlign::Middle => pos.y + ascent - text_h / 2.0,
333            VAlign::Baseline => pos.y,
334            VAlign::Bottom => pos.y - (text_h - ascent),
335        };
336
337        // Apply transform.
338        let coeffs = transform.as_coeffs();
339        let tx = coeffs[0] * adjusted_x + coeffs[2] * adjusted_y + coeffs[4];
340        let ty = coeffs[1] * adjusted_x + coeffs[3] * adjusted_y + coeffs[5];
341
342        let pdf_x = px_to_mm(tx);
343        let pdf_y = px_to_mm(self.flip_y(ty));
344
345        let layer = self.current_layer();
346        let text_color = Self::convert_color(&style.color);
347        layer.set_fill_color(text_color);
348        layer.use_text(text, font_size_pt as f32, pdf_x, pdf_y, &font);
349    }
350
351    fn draw_image(&mut self, _img: &Image, _dst: Rect, _transform: Affine) {
352        // Image embedding in PDF is not yet implemented.
353    }
354
355    fn push_clip(&mut self, path: &Path, transform: Affine) {
356        let layer = self.current_layer();
357        layer.save_graphics_state();
358        self.clip_depth += 1;
359
360        // Build and apply a clipping polygon.
361        let rings = self.convert_path_to_rings(path, transform);
362        if !rings.is_empty() {
363            let poly = Polygon {
364                rings,
365                mode: PaintMode::Clip,
366                winding_order: WindingOrder::NonZero,
367            };
368            layer.add_polygon(poly);
369        }
370    }
371
372    fn pop_clip(&mut self) {
373        if self.clip_depth > 0 {
374            let layer = self.current_layer();
375            layer.restore_graphics_state();
376            self.clip_depth -= 1;
377        }
378    }
379
380    fn measure_text(&self, text: &str, style: &TextStyle) -> (f64, f64) {
381        if text.is_empty() {
382            return (0.0, 0.0);
383        }
384        // Approximate measurement: average character width is roughly 0.6 * font size
385        // for Helvetica. This matches the SVG renderer approach.
386        let width = text.len() as f64 * style.size * 0.6;
387        let height = style.size;
388        (width, height)
389    }
390
391    fn finalize(self) -> Vec<u8> {
392        self.doc
393            .save_to_bytes()
394            .expect("failed to save PDF to bytes")
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn create_renderer() {
404        let r = PdfRenderer::new(800, 600);
405        assert_eq!(r.size(), (800, 600));
406    }
407
408    #[test]
409    fn finalize_produces_pdf() {
410        let r = PdfRenderer::new(100, 100);
411        let bytes = r.finalize();
412        // PDF magic bytes: %PDF
413        assert!(bytes.len() > 4);
414        assert_eq!(&bytes[..5], b"%PDF-");
415    }
416
417    #[test]
418    fn fill_rect_does_not_panic() {
419        let mut r = PdfRenderer::new(200, 200);
420        let path = Path::rect(Rect::new(10.0, 10.0, 50.0, 50.0));
421        let paint = Paint::new(Color::TAB_BLUE);
422        r.fill_path(&path, &paint, Affine::IDENTITY);
423        let bytes = r.finalize();
424        assert!(!bytes.is_empty());
425    }
426
427    #[test]
428    fn stroke_rect_does_not_panic() {
429        let mut r = PdfRenderer::new(200, 200);
430        let path = Path::rect(Rect::new(10.0, 10.0, 50.0, 50.0));
431        let paint = Paint::new(Color::TAB_RED);
432        let stroke = Stroke::new(2.0);
433        r.stroke_path(&path, &paint, &stroke, Affine::IDENTITY);
434        let bytes = r.finalize();
435        assert!(!bytes.is_empty());
436    }
437
438    #[test]
439    fn draw_text_does_not_panic() {
440        let mut r = PdfRenderer::new(200, 200);
441        let style = TextStyle::new(14.0);
442        r.draw_text(
443            "Hello PDF",
444            Point::new(10.0, 50.0),
445            &style,
446            Affine::IDENTITY,
447        );
448        let bytes = r.finalize();
449        assert!(!bytes.is_empty());
450    }
451
452    #[test]
453    fn measure_text_returns_nonzero() {
454        let r = PdfRenderer::new(100, 100);
455        let style = TextStyle::new(14.0);
456        let (w, h) = r.measure_text("hello", &style);
457        assert!(w > 0.0, "text width should be positive, got {w}");
458        assert!(h > 0.0, "text height should be positive, got {h}");
459    }
460
461    #[test]
462    fn measure_text_empty() {
463        let r = PdfRenderer::new(100, 100);
464        let style = TextStyle::new(14.0);
465        let (w, h) = r.measure_text("", &style);
466        assert!((w - 0.0).abs() < f64::EPSILON);
467        assert!((h - 0.0).abs() < f64::EPSILON);
468    }
469
470    #[test]
471    fn clip_push_pop_does_not_panic() {
472        let mut r = PdfRenderer::new(200, 200);
473        let clip = Path::rect(Rect::new(0.0, 0.0, 100.0, 100.0));
474        r.push_clip(&clip, Affine::IDENTITY);
475        let path = Path::rect(Rect::new(10.0, 10.0, 50.0, 50.0));
476        let paint = Paint::new(Color::TAB_GREEN);
477        r.fill_path(&path, &paint, Affine::IDENTITY);
478        r.pop_clip();
479        let bytes = r.finalize();
480        assert!(!bytes.is_empty());
481    }
482
483    #[test]
484    fn circle_path_does_not_panic() {
485        let mut r = PdfRenderer::new(200, 200);
486        let path = Path::circle(Point::new(100.0, 100.0), 40.0);
487        let paint = Paint::new(Color::TAB_ORANGE);
488        r.fill_path(&path, &paint, Affine::IDENTITY);
489        let bytes = r.finalize();
490        assert!(!bytes.is_empty());
491    }
492
493    #[test]
494    fn stroke_with_dash_does_not_panic() {
495        let mut r = PdfRenderer::new(200, 200);
496        let path = Path::rect(Rect::new(10.0, 10.0, 100.0, 100.0));
497        let paint = Paint::new(Color::BLACK);
498        let stroke = Stroke::new(1.5).with_dash(DashPattern {
499            dashes: vec![5.0, 3.0],
500            offset: 0.0,
501        });
502        r.stroke_path(&path, &paint, &stroke, Affine::IDENTITY);
503        let bytes = r.finalize();
504        assert!(!bytes.is_empty());
505    }
506
507    #[test]
508    fn px_to_mm_conversion() {
509        // 72 pixels = 1 inch = 25.4 mm
510        let mm = px_to_mm(72.0);
511        assert!(
512            (mm.0 - 25.4).abs() < 0.01,
513            "72px should be 25.4mm, got {}",
514            mm.0
515        );
516    }
517
518    #[test]
519    fn multiple_fills_produce_valid_pdf() {
520        let mut r = PdfRenderer::new(400, 400);
521        // Fill a white background.
522        let bg = Path::rect(Rect::new(0.0, 0.0, 400.0, 400.0));
523        r.fill_path(&bg, &Paint::new(Color::WHITE), Affine::IDENTITY);
524        // Fill a colored rectangle.
525        let rect = Path::rect(Rect::new(50.0, 50.0, 100.0, 100.0));
526        r.fill_path(&rect, &Paint::new(Color::TAB_BLUE), Affine::IDENTITY);
527        // Stroke a line.
528        let mut line = Path::new();
529        line.move_to(10.0, 10.0);
530        line.line_to(390.0, 390.0);
531        r.stroke_path(&line, &Paint::new(Color::TAB_RED), &Stroke::new(2.0), Affine::IDENTITY);
532        let bytes = r.finalize();
533        assert_eq!(&bytes[..5], b"%PDF-");
534    }
535
536    #[test]
537    fn text_alignment_does_not_panic() {
538        let mut r = PdfRenderer::new(300, 300);
539        let mut style = TextStyle::new(16.0);
540
541        style.halign = HAlign::Left;
542        style.valign = VAlign::Top;
543        r.draw_text("Top-Left", Point::new(150.0, 50.0), &style, Affine::IDENTITY);
544
545        style.halign = HAlign::Center;
546        style.valign = VAlign::Middle;
547        r.draw_text("Center", Point::new(150.0, 150.0), &style, Affine::IDENTITY);
548
549        style.halign = HAlign::Right;
550        style.valign = VAlign::Bottom;
551        r.draw_text("Bottom-Right", Point::new(150.0, 250.0), &style, Affine::IDENTITY);
552
553        let bytes = r.finalize();
554        assert!(!bytes.is_empty());
555    }
556}