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
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 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 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 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 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 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 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 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 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 let (text_w, text_h) = self.measure_text(text, style);
330
331 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 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 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 }
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 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 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 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 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 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 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 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}