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