1#![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
21const PX_TO_MM: f64 = 25.4 / 72.0;
25
26#[inline]
28fn px_to_mm(px: f64) -> Mm {
29 Mm((px * PX_TO_MM) as f32)
30}
31
32pub struct PdfRenderer {
38 width: u32,
39 height: u32,
40 doc: PdfDocumentReference,
41 page_idx: PdfPageIndex,
42 layer_idx: PdfLayerIndex,
43 clip_depth: usize,
45 ring_scratch: Vec<(printpdf::Point, bool)>,
49}
50
51impl PdfRenderer {
52 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 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 #[inline]
82 fn flip_y(&self, y: f64) -> f64 {
83 self.height as f64 - y
84 }
85
86 #[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 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 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 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 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 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 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 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 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 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 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 let (text_w, text_h) = self.measure_text(text, style);
333
334 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 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 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 }
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 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 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 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 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 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 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 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}