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