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}
46
47impl PdfRenderer {
48 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 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 #[inline]
77 fn flip_y(&self, y: f64) -> f64 {
78 self.height as f64 - y
79 }
80
81 #[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 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 if let Some(last) = current_ring.last_mut() {
121 let p0x = last.0.x.0;
122 let p0y = last.0.y.0;
123 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 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 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 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 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 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 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 let (text_w, text_h) = self.measure_text(text, style);
319
320 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 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 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 }
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 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 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 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 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 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 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 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}