Skip to main content

slidy/puzzle/
render.rs

1//! Defines the [`Renderer`] struct for creating SVG images of [`SlidingPuzzle`]s.
2
3use std::{fmt::Display, ops::Deref};
4
5use num_traits::Zero as _;
6use palette::rgb::Rgba;
7use svg::{
8    node::{
9        element::{Group, Rectangle, Style, Text as TextElement},
10        Text as TextNode,
11    },
12    Document,
13};
14use thiserror::Error;
15
16use crate::puzzle::{
17    color_scheme::{Black, ColorScheme},
18    size::Size,
19    sliding_puzzle::SlidingPuzzle,
20};
21
22#[cfg(feature = "serde")]
23use serde::{Deserialize, Serialize};
24
25/// Error type for [`Renderer`].
26#[derive(Clone, Debug, Error, PartialEq, Eq)]
27#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
28pub enum RendererError {
29    /// Returned when the given puzzle size is incompatible with the label.
30    #[error("IncompatibleLabel: puzzle size ({0}) can not be used with the given label")]
31    IncompatibleLabel(Size),
32}
33
34/// A font that can be used with [`Renderer`].
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub enum Font<'a> {
37    /// A font installed on the system, specified by the font name.
38    Family(&'a str),
39    /// A font defined by a URL (including a local file path) and a font format.
40    Url {
41        /// Path to the font
42        path: &'a str,
43        /// Format of the font file.
44        format: &'a str,
45    },
46    /// A font defined by base 64 data and a font format.
47    Base64 {
48        /// Base 64 font data.
49        data: &'a str,
50        /// Format of the font data.
51        format: &'a str,
52    },
53}
54
55/// Struct containing the information needed to draw the borders of the puzzle.
56#[derive(Clone, Debug, PartialEq)]
57pub struct Borders<S: ColorScheme> {
58    scheme: S,
59    thickness: f32,
60}
61
62impl Borders<Black> {
63    /// Creates a new [`Borders`] instance using the [`Black`] [`ColorScheme`].
64    #[must_use]
65    pub fn new() -> Self {
66        Self::with_scheme(Black)
67    }
68}
69
70impl Default for Borders<Black> {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl<S: ColorScheme> Borders<S> {
77    /// Create a new [`Borders`] instance. The default is a 1 pixel wide black border.
78    #[must_use]
79    pub fn with_scheme(scheme: S) -> Self {
80        Self {
81            scheme,
82            thickness: 1.0,
83        }
84    }
85
86    /// Set the border color scheme.
87    ///
88    /// If the main color scheme (see [`RendererBuilder::with_scheme`]) has a subscheme, and the
89    /// subscheme style (see [`RendererBuilder::subscheme_style`]) is
90    /// [`SubschemeStyle::BorderColor`], then the subscheme color will override the border scheme.
91    #[must_use]
92    pub fn scheme(mut self, scheme: S) -> Self {
93        self.scheme = scheme;
94        self
95    }
96
97    /// Set the border thickness.
98    #[must_use]
99    pub fn thickness(mut self, thickness: f32) -> Self {
100        self.thickness = thickness;
101        self
102    }
103}
104
105/// Struct containing the information needed to draw text on the pieces of the puzzle.
106#[derive(Clone, Debug, PartialEq)]
107pub struct Text<'a, S: ColorScheme> {
108    scheme: S,
109    font: Font<'a>,
110    font_size: f32,
111    position: (f32, f32),
112}
113
114impl Text<'_, Black> {
115    /// Creates a new [`Text`] instance using the [`Black`] [`ColorScheme`].
116    #[must_use]
117    pub fn new() -> Self {
118        Self::with_scheme(Black)
119    }
120}
121
122impl Default for Text<'_, Black> {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128impl<'a, S: ColorScheme> Text<'a, S> {
129    /// Create a new [`Text`] instance.
130    #[must_use]
131    pub fn with_scheme(scheme: S) -> Self {
132        Self {
133            scheme,
134            font: Font::Family("sans-serif"),
135            font_size: 30.0,
136            position: (0.5, 0.5),
137        }
138    }
139
140    /// Set the text color scheme.
141    ///
142    /// If the main color scheme (see [`RendererBuilder::with_scheme`]) has a subscheme, and the
143    /// subscheme style (see [`RendererBuilder::subscheme_style`]) is
144    /// [`SubschemeStyle::TextColor`], then the subscheme color will override the text scheme.
145    #[must_use]
146    pub fn scheme(mut self, scheme: S) -> Self {
147        self.scheme = scheme;
148        self
149    }
150
151    /// Set the font.
152    #[must_use]
153    pub fn font(mut self, font: Font<'a>) -> Self {
154        self.font = font;
155        self
156    }
157
158    /// Set the font size.
159    #[must_use]
160    pub fn font_size(mut self, size: f32) -> Self {
161        self.font_size = size.max(0.0);
162        self
163    }
164
165    /// Set the position around which the text within each tile will be centered, as a fraction of
166    /// the tile size. (0, 0) is the top left of the tile and (1, 1) is the bottom right. This is
167    /// useful if your font does not render perfectly centered.
168    #[must_use]
169    pub fn position(mut self, pos: (f32, f32)) -> Self {
170        self.position = pos;
171        self
172    }
173
174    /// Write the formatting options into a CSS string.
175    #[must_use]
176    pub fn style_string(&self) -> String {
177        if let Font::Family(f) = self.font {
178            format!(
179                "text {{ font-family: {f}; font-size: {fs}px; }}",
180                fs = self.font_size
181            )
182        } else {
183            let src = match self.font {
184                Font::Family(_) => unreachable!(),
185                Font::Url { path, format } => {
186                    format!(r#"url({path}) format("{format}")"#)
187                }
188                Font::Base64 { data, format } => {
189                    format!(r#"url(data:font/ttf;base64,{data}) format("{format}")"#)
190                }
191            };
192
193            format!(
194                "@font-face {{ \
195                    font-family: f; \
196                    src: {src}; \
197                }} \
198                text {{ \
199                    font-family: f; \
200                    font-size: {fs}px; \
201                }}",
202                fs = self.font_size
203            )
204        }
205    }
206}
207
208/// Ways that the subscheme can be displayed on the puzzle.
209///
210/// The default value is [`SubschemeStyle::Rectangle`].
211#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
212#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
213pub enum SubschemeStyle {
214    /// Draw the subscheme as a small rectangle at the bottom of each piece.
215    #[default]
216    Rectangle,
217    /// Display the subscheme using the text color.
218    TextColor,
219    /// Display the subscheme using the border color.
220    BorderColor,
221}
222
223/// Used to build a [`Renderer`].
224#[derive(Clone, Debug, PartialEq)]
225pub struct RendererBuilder<
226    'a,
227    S: ColorScheme = Box<dyn ColorScheme + 'a>,
228    U: ColorScheme = Box<dyn ColorScheme + 'a>,
229    T: ColorScheme = Box<dyn ColorScheme + 'a>,
230    B: ColorScheme = Box<dyn ColorScheme + 'a>,
231> {
232    scheme: S,
233    subscheme: Option<U>,
234    borders: Option<Borders<B>>,
235    text: Option<Text<'a, T>>,
236    tile_size: f32,
237    tile_rounding: f32,
238    tile_gap: f32,
239    padding: f32,
240    subscheme_style: Option<SubschemeStyle>,
241    background_color: Rgba,
242}
243
244/// Draws a [`SlidingPuzzle`] as an SVG image.
245#[derive(Clone, Debug, PartialEq)]
246pub struct Renderer<
247    'a,
248    S: ColorScheme = Box<dyn ColorScheme + 'a>,
249    U: ColorScheme = Box<dyn ColorScheme + 'a>,
250    T: ColorScheme = Box<dyn ColorScheme + 'a>,
251    B: ColorScheme = Box<dyn ColorScheme + 'a>,
252>(RendererBuilder<'a, S, U, T, B>);
253
254impl<'a, S: ColorScheme, U: ColorScheme, T: ColorScheme, B: ColorScheme> Deref
255    for Renderer<'a, S, U, T, B>
256{
257    type Target = RendererBuilder<'a, S, U, T, B>;
258
259    fn deref(&self) -> &Self::Target {
260        &self.0
261    }
262}
263
264impl<'a> RendererBuilder<'a> {
265    /// Create a new [`RendererBuilder`] with the default color scheme.
266    #[must_use]
267    pub fn with_dyn_scheme(scheme: Box<dyn ColorScheme + 'a>) -> Self {
268        Self::with_scheme(scheme)
269    }
270}
271
272impl<'a, S: ColorScheme, U: ColorScheme, T: ColorScheme, B: ColorScheme>
273    RendererBuilder<'a, S, U, T, B>
274{
275    /// Create a new [`RendererBuilder`].
276    #[must_use]
277    pub fn with_scheme(scheme: S) -> Self {
278        Self {
279            scheme,
280            subscheme: None,
281            borders: None,
282            text: None,
283            tile_size: 75.0,
284            tile_rounding: 0.0,
285            tile_gap: 0.0,
286            padding: 0.0,
287            subscheme_style: Some(SubschemeStyle::Rectangle),
288            background_color: Rgba::new(1.0, 1.0, 1.0, 0.0),
289        }
290    }
291
292    /// Set the color scheme.
293    #[must_use]
294    pub fn scheme(mut self, scheme: S) -> Self {
295        self.scheme = scheme;
296        self
297    }
298
299    /// Set the subscheme.
300    #[must_use]
301    pub fn subscheme(mut self, subscheme: U) -> Self {
302        self.subscheme = Some(subscheme);
303        self
304    }
305
306    /// Set the borders.
307    #[must_use]
308    pub fn borders(mut self, borders: Borders<B>) -> Self {
309        self.borders = Some(borders);
310        self
311    }
312
313    /// Set the text.
314    #[must_use]
315    pub fn text(mut self, text: Text<'a, T>) -> Self {
316        self.text = Some(text);
317        self
318    }
319
320    /// Set the tile size in pixels.
321    #[must_use]
322    pub fn tile_size(mut self, size: f32) -> Self {
323        self.tile_size = size.max(0.0);
324        self
325    }
326
327    /// Set the rounding radius of the tile corners in pixels.
328    #[must_use]
329    pub fn tile_rounding(mut self, rounding: f32) -> Self {
330        self.tile_rounding = rounding.max(0.0);
331        self
332    }
333
334    /// Set the gap between tiles in pixels.
335    #[must_use]
336    pub fn tile_gap(mut self, gap: f32) -> Self {
337        self.tile_gap = gap;
338        self
339    }
340
341    /// Set the padding around the edge of the puzzle in pixels.
342    #[must_use]
343    pub fn padding(mut self, padding: f32) -> Self {
344        self.padding = padding;
345        self
346    }
347
348    /// Set the subscheme style.
349    #[must_use]
350    pub fn subscheme_style(mut self, style: SubschemeStyle) -> Self {
351        self.subscheme_style = Some(style);
352        self
353    }
354
355    /// Set the background color.
356    #[must_use]
357    pub fn background_color(mut self, color: Rgba) -> Self {
358        self.background_color = color;
359        self
360    }
361
362    /// Builds a [`Renderer`].
363    #[must_use]
364    pub fn build(self) -> Renderer<'a, S, U, T, B> {
365        Renderer(self)
366    }
367}
368
369impl<S: ColorScheme, U: ColorScheme, T: ColorScheme, B: ColorScheme> Renderer<'_, S, U, T, B> {
370    /// Returns the CSS string used to style the image.
371    pub fn style_string(&self) -> String {
372        let font = self
373            .text
374            .as_ref()
375            .map(|a| a.style_string())
376            .unwrap_or_default();
377
378        let bg = {
379            let color: Rgba<_, u8> = self.background_color.into_format();
380            format!("#{color:x}")
381        };
382
383        let border_thickness = self
384            .borders
385            .as_ref()
386            .map(|a| a.thickness)
387            .unwrap_or_default();
388
389        format!(
390            "svg {{ background-color: {bg}; }} \
391            text {{ \
392                text-anchor: middle; \
393                dominant-baseline: central; \
394            }} \
395            rect.piece {{ \
396                width: {ts}px; \
397                height: {ts}px; \
398                rx: {tr}px; \
399                ry: {tr}px; \
400                stroke-width: {sw}px; \
401            }} \
402            rect.sub {{ \
403                width: {srw}px; \
404                height: {srh}px; \
405            }} \
406            {font}",
407            ts = self.tile_size,
408            tr = self.tile_rounding,
409            sw = border_thickness,
410            srw = self.tile_size * 0.7,
411            srh = self.tile_size * 0.1,
412        )
413    }
414
415    /// Draws `puzzle` as an SVG image, wrapped in an SVG group element.
416    pub fn group<Puzzle>(&self, puzzle: &Puzzle) -> Result<Group, RendererError>
417    where
418        Puzzle: SlidingPuzzle,
419        Puzzle::Piece: Display,
420    {
421        let size = puzzle.size();
422        let (width, height) = size.into();
423
424        let mut group = Group::new();
425
426        for y in 0..height {
427            for x in 0..width {
428                let piece = puzzle.piece_at_xy((x, y));
429
430                if piece != Puzzle::Piece::zero() {
431                    group = group.add(self.render_piece(puzzle, (x, y)));
432                }
433            }
434        }
435
436        Ok(group)
437    }
438
439    /// Draws the piece of `puzzle` at position `(x, y)` as an SVG image, wrapped in an SVG group
440    /// element.
441    pub fn render_piece<Puzzle>(&self, puzzle: &Puzzle, (x, y): (u64, u64)) -> Group
442    where
443        Puzzle: SlidingPuzzle,
444        Puzzle::Piece: Display,
445    {
446        let size = puzzle.size();
447
448        let border_thickness = self
449            .borders
450            .as_ref()
451            .map(|a| a.thickness)
452            .unwrap_or_default();
453
454        let piece = puzzle.piece_at_xy((x, y));
455        let solved_pos = puzzle.solved_pos_xy(piece);
456
457        let (x, y) = (x as f32, y as f32);
458
459        let rect_pos = (
460            self.padding
461                + border_thickness / 2.0
462                + (self.tile_size + self.tile_gap + border_thickness) * x,
463            self.padding
464                + border_thickness / 2.0
465                + (self.tile_size + self.tile_gap + border_thickness) * y,
466        );
467
468        let subscheme_color = self
469            .subscheme
470            .as_ref()
471            .map(|subscheme| subscheme.color(size, solved_pos));
472
473        // Macro to get the color that we want for text and border colors, as a hex string.
474        // If `self.subscheme_style` is TextColor or BorderColor, then this will override the
475        // schemes that we have in self.text_scheme and self.borders.unwrap().scheme.
476        macro_rules! color {
477            ($scheme:expr, $subscheme:expr) => {{
478                // If there is a subscheme color, and the subscheme style overrides the other
479                // scheme (text or border scheme), then we use the subscheme color.
480                // Otherwise, we use the text or border scheme color.
481                let color = subscheme_color
482                    .filter(|_| self.subscheme_style == Some($subscheme))
483                    .unwrap_or_else(|| $scheme.color(size, solved_pos));
484
485                // Format as hex string
486                let color: Rgba<_, u8> = color.into_format();
487                format!("#{color:x}")
488            }};
489        }
490
491        let rect = {
492            let fill = {
493                let color: Rgba<_, u8> = self.scheme.color(size, solved_pos).into_format();
494                format!("#{color:x}")
495            };
496
497            let mut r = Rectangle::new()
498                .set("x", rect_pos.0)
499                .set("y", rect_pos.1)
500                .set("class", "piece")
501                .set("fill", fill);
502
503            if let Some(s) = &self.borders {
504                let stroke = color!(s.scheme, SubschemeStyle::BorderColor);
505                r = r.set("stroke", stroke);
506            }
507
508            r
509        };
510
511        let text = self.text.as_ref().map(|text| {
512            let fill = color!(text.scheme, SubschemeStyle::TextColor);
513            let (tx, ty) = text.position;
514
515            TextElement::new("")
516                .set("x", rect_pos.0 + self.tile_size * tx)
517                .set("y", rect_pos.1 + self.tile_size * ty)
518                .set("fill", fill)
519                .add(TextNode::new(piece.to_string()))
520        });
521
522        let subscheme_render = subscheme_color
523            .filter(|_| self.subscheme_style == Some(SubschemeStyle::Rectangle))
524            .map(|subcolor| {
525                let fill = {
526                    let color: Rgba<_, u8> = subcolor.into_format();
527                    format!("#{color:x}")
528                };
529
530                let subrect_pos = (0.15, 0.8);
531
532                Rectangle::new()
533                    .set("x", rect_pos.0 + self.tile_size * subrect_pos.0)
534                    .set("y", rect_pos.1 + self.tile_size * subrect_pos.1)
535                    .set("class", "sub")
536                    .set("fill", fill)
537            });
538
539        let mut group = Group::new().add(rect);
540
541        if let Some(text) = text {
542            group = group.add(text);
543        }
544
545        if let Some(s) = subscheme_render {
546            group = group.add(s);
547        }
548
549        group
550    }
551
552    /// Draws `puzzle` as an SVG image.
553    pub fn render<Puzzle>(&self, puzzle: &Puzzle) -> Result<Document, RendererError>
554    where
555        Puzzle: SlidingPuzzle,
556        Puzzle::Piece: Display,
557    {
558        let size = puzzle.size();
559        let (width, height) = size.into();
560
561        let border_thickness = self
562            .borders
563            .as_ref()
564            .map(|a| a.thickness)
565            .unwrap_or_default();
566
567        let (w, h) = (width as f32, height as f32);
568        let (image_w, image_h) = (
569            w * self.tile_size
570                + (w - 1.0) * self.tile_gap
571                + w * border_thickness
572                + 2.0 * self.padding,
573            h * self.tile_size
574                + (h - 1.0) * self.tile_gap
575                + h * border_thickness
576                + 2.0 * self.padding,
577        );
578
579        let style_str = self.style_string();
580
581        let doc = Document::new()
582            .add(Style::new(style_str))
583            .add(self.group(puzzle)?)
584            .set("width", image_w)
585            .set("height", image_h);
586
587        Ok(doc)
588    }
589}