Skip to main content

qrcode_generator/
render.rs

1use alloc::{string::String, vec, vec::Vec};
2use core::fmt;
3#[cfg(feature = "std")]
4use std::{
5    io::{self, Write as IoWrite},
6    path::Path,
7};
8
9#[cfg(feature = "std")]
10use atomic_write_file::AtomicWriteFile;
11#[cfg(feature = "image")]
12use image::{
13    ColorType, ImageBuffer, ImageEncoder, Luma,
14    codecs::png::{CompressionType, FilterType, PngEncoder},
15};
16#[cfg(feature = "tokio")]
17use tokio::io::{AsyncWrite as TokioAsyncWrite, AsyncWriteExt};
18
19use crate::{RenderError, Symbol, SymbolVersion};
20
21/// Renders an encoded symbol at exact output dimensions.
22#[derive(Clone, Copy, Debug)]
23pub struct Renderer<'a> {
24    symbol:     &'a Symbol,
25    width:      usize,
26    height:     usize,
27    quiet_zone: usize,
28}
29
30impl<'a> Renderer<'a> {
31    /// Creates a renderer with an exact square output size and the standard quiet zone for the symbol family.
32    #[inline]
33    pub const fn new(symbol: &'a Symbol, size: usize) -> Self {
34        Self::new_with_dimensions(symbol, size, size)
35    }
36
37    /// Creates a renderer with exact output dimensions and the standard quiet zone for the symbol family.
38    #[inline]
39    pub const fn new_with_dimensions(symbol: &'a Symbol, width: usize, height: usize) -> Self {
40        let quiet_zone = match symbol.version() {
41            #[cfg(feature = "qr")]
42            SymbolVersion::Qr(_) => 4,
43            #[cfg(feature = "micro-qr")]
44            SymbolVersion::Micro(_) => 2,
45            #[cfg(feature = "rmqr")]
46            SymbolVersion::Rmqr(_) => 2,
47        };
48
49        Self {
50            symbol,
51            width,
52            height,
53            quiet_zone,
54        }
55    }
56
57    /// Sets the minimum quiet zone in modules before any extra centering pixels.
58    #[must_use]
59    #[inline]
60    pub const fn quiet_zone(mut self, modules: usize) -> Self {
61        self.quiet_zone = modules;
62
63        self
64    }
65
66    /// Renders an 8-bit grayscale image in row-major order.
67    pub fn to_luma8(self) -> Result<Vec<u8>, RenderError> {
68        let layout = self.layout()?;
69        let length = self.width.checked_mul(self.height).ok_or(RenderError::ImageSizeTooLarge)?;
70        let mut image = vec![255; length];
71        let symbol_width = self.symbol.width();
72        let modules = self.symbol.modules();
73
74        for y in 0..self.symbol.height() {
75            let first_row = layout.margin_y + y * layout.scale;
76            let row_start = first_row * self.width;
77
78            // The row is read directly from the module slice because x and y are always in range here.
79            let module_row = &modules[y * symbol_width..][..symbol_width];
80
81            // Each horizontal run of dark modules is drawn once into the first pixel row.
82            let mut x = 0;
83
84            while x < symbol_width {
85                if !module_row[x] {
86                    x += 1;
87                    continue;
88                }
89
90                let start = x;
91
92                while x < symbol_width && module_row[x] {
93                    x += 1;
94                }
95
96                let output_x = layout.margin_x + start * layout.scale;
97
98                image[row_start + output_x..row_start + output_x + (x - start) * layout.scale]
99                    .fill(0);
100            }
101
102            // The finished pixel row is copied to the remaining rows of this module row.
103            for row in 1..layout.scale {
104                let destination = (first_row + row) * self.width;
105
106                image.copy_within(row_start..row_start + self.width, destination);
107            }
108        }
109
110        Ok(image)
111    }
112
113    /// Writes an SVG document to a writer.
114    ///
115    /// The description must contain only characters allowed by XML 1.0; markup characters are escaped, but callers must remove or replace disallowed XML characters before rendering.
116    /// Pass `None::<&str>` when no description is needed.
117    #[cfg(feature = "std")]
118    pub fn write_svg<W: IoWrite>(
119        self,
120        writer: W,
121        description: Option<impl AsRef<str>>,
122    ) -> Result<(), RenderError> {
123        let description = description.as_ref().map(AsRef::as_ref);
124        let layout = self.layout()?;
125        let mut writer = IoFmtWriter {
126            inner: writer, error: None
127        };
128
129        if self.write_svg_content(&mut writer, description, layout).is_err() {
130            return Err(writer.error.expect("the I/O adapter stores formatting errors").into());
131        }
132
133        writer.inner.flush()?;
134        Ok(())
135    }
136
137    /// Renders an SVG document as a UTF-8 string.
138    ///
139    /// The description must contain only characters allowed by XML 1.0; markup characters are escaped, but callers must remove or replace disallowed XML characters before rendering.
140    /// Pass `None::<&str>` when no description is needed.
141    pub fn to_svg_string(
142        self,
143        description: Option<impl AsRef<str>>,
144    ) -> Result<String, RenderError> {
145        let description = description.as_ref().map(AsRef::as_ref);
146        let layout = self.layout()?;
147        let mut svg = String::with_capacity(8192);
148
149        self.write_svg_content(&mut svg, description, layout)
150            .expect("writing an SVG to a String cannot fail");
151
152        Ok(svg)
153    }
154
155    fn write_svg_content<W: fmt::Write>(
156        self,
157        writer: &mut W,
158        description: Option<&str>,
159        layout: Layout,
160    ) -> fmt::Result {
161        write!(
162            writer,
163            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg width=\"{}\" height=\"{}\" shape-rendering=\"crispEdges\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\">\n",
164            self.width, self.height
165        )?;
166
167        if let Some(description) = description {
168            if !description.is_empty() {
169                writer.write_str("\t<desc>")?;
170                writer.write_str(&html_escape::encode_safe(description))?;
171                writer.write_str("</desc>\n")?;
172            }
173        } else {
174            writeln!(
175                writer,
176                "\t<desc>{} {} by magiclen.org</desc>",
177                env!("CARGO_PKG_NAME"),
178                env!("CARGO_PKG_VERSION")
179            )?;
180        }
181
182        write!(
183            writer,
184            "\t<rect width=\"{}\" height=\"{}\" fill=\"#FFF\"/>\n\t<path d=\"",
185            self.width, self.height
186        )?;
187
188        let symbol_width = self.symbol.width();
189        let modules = self.symbol.modules();
190
191        for y in 0..self.symbol.height() {
192            // The row is read directly from the module slice because x and y are always in range here.
193            let module_row = &modules[y * symbol_width..][..symbol_width];
194
195            // One SVG rectangle command represents each horizontal run of dark modules.
196            let mut x = 0;
197
198            while x < symbol_width {
199                if !module_row[x] {
200                    x += 1;
201                    continue;
202                }
203
204                let start = x;
205
206                while x < symbol_width && module_row[x] {
207                    x += 1;
208                }
209
210                let output_x = layout.margin_x + start * layout.scale;
211                let output_y = layout.margin_y + y * layout.scale;
212                let width = (x - start) * layout.scale;
213
214                write!(
215                    writer,
216                    "M{output_x} {output_y}h{width}v{}H{output_x}V{output_y}",
217                    layout.scale
218                )?;
219            }
220        }
221        writer.write_str("\"/>\n</svg>")
222    }
223
224    #[cfg(feature = "tokio")]
225    /// Renders an SVG document in memory, then writes and flushes it to a tokio asynchronous writer.
226    ///
227    /// The description must contain only characters allowed by XML 1.0; markup characters are escaped, but callers must remove or replace disallowed XML characters before rendering.
228    /// Pass `None::<&str>` when no description is needed.
229    pub async fn write_svg_async<W: TokioAsyncWrite + Unpin>(
230        self,
231        mut writer: W,
232        description: Option<impl AsRef<str>>,
233    ) -> Result<(), RenderError> {
234        let svg = self.to_svg_string(description)?;
235
236        writer.write_all(svg.as_bytes()).await?;
237        writer.flush().await?;
238
239        Ok(())
240    }
241
242    /// Atomically saves an SVG document after rendering succeeds.
243    ///
244    /// The description must contain only characters allowed by XML 1.0; markup characters are escaped, but callers must remove or replace disallowed XML characters before rendering.
245    /// Pass `None::<&str>` when no description is needed.
246    #[cfg(feature = "std")]
247    pub fn save_svg(
248        self,
249        path: impl AsRef<Path>,
250        description: Option<impl AsRef<str>>,
251    ) -> Result<(), RenderError> {
252        let mut file = AtomicWriteFile::open(path)?;
253
254        // Buffering coalesces the many small SVG path writes into a few large writes to the file.
255        self.write_svg(io::BufWriter::new(&mut file), description)?;
256
257        file.commit()?;
258
259        Ok(())
260    }
261
262    #[cfg(feature = "tokio")]
263    /// Atomically saves an SVG document after rendering succeeds, offloading the write to tokio's blocking pool.
264    ///
265    /// Like [`save_svg`](Self::save_svg), the write goes through a temporary file, so an existing file is left untouched if it fails.
266    /// The description must contain only characters allowed by XML 1.0; markup characters are escaped, but callers must remove or replace disallowed XML characters before rendering.
267    /// Pass `None::<&str>` when no description is needed.
268    pub async fn save_svg_async(
269        self,
270        path: impl AsRef<Path>,
271        description: Option<impl AsRef<str>>,
272    ) -> Result<(), RenderError> {
273        let svg = self.to_svg_string(description)?;
274
275        save_atomic_blocking(path.as_ref().to_path_buf(), svg.into_bytes()).await
276    }
277
278    #[cfg(feature = "image")]
279    /// Writes a grayscale PNG image to a writer.
280    pub fn write_png<W: IoWrite>(self, writer: W) -> Result<(), RenderError> {
281        let image = self.to_luma8()?;
282
283        let width = u32::try_from(self.width).map_err(|_| RenderError::ImageSizeTooLarge)?;
284        let height = u32::try_from(self.height).map_err(|_| RenderError::ImageSizeTooLarge)?;
285
286        PngEncoder::new_with_quality(writer, CompressionType::Best, FilterType::NoFilter)
287            .write_image(&image, width, height, ColorType::L8.into())?;
288
289        Ok(())
290    }
291
292    #[cfg(feature = "image")]
293    /// Renders a grayscale PNG image into a byte vector.
294    pub fn to_png_vec(self) -> Result<Vec<u8>, RenderError> {
295        let mut bytes = Vec::with_capacity(4096);
296
297        self.write_png(&mut bytes)?;
298
299        Ok(bytes)
300    }
301
302    #[cfg(all(feature = "tokio", feature = "image"))]
303    /// Renders a PNG image in memory, then writes and flushes it to a tokio asynchronous writer.
304    pub async fn write_png_async<W: TokioAsyncWrite + Unpin>(
305        self,
306        mut writer: W,
307    ) -> Result<(), RenderError> {
308        let png = self.to_png_vec()?;
309
310        writer.write_all(&png).await?;
311        writer.flush().await?;
312
313        Ok(())
314    }
315
316    #[cfg(feature = "image")]
317    /// Atomically saves a PNG image after rendering succeeds.
318    pub fn save_png(self, path: impl AsRef<Path>) -> Result<(), RenderError> {
319        let mut file = AtomicWriteFile::open(path)?;
320
321        self.write_png(&mut file)?;
322
323        file.commit()?;
324
325        Ok(())
326    }
327
328    #[cfg(all(feature = "tokio", feature = "image"))]
329    /// Atomically saves a PNG image after rendering succeeds, offloading the write to tokio's blocking pool.
330    ///
331    /// Like [`save_png`](Self::save_png), the write goes through a temporary file, so an existing file is left untouched if it fails.
332    pub async fn save_png_async(self, path: impl AsRef<Path>) -> Result<(), RenderError> {
333        let png = self.to_png_vec()?;
334
335        save_atomic_blocking(path.as_ref().to_path_buf(), png).await
336    }
337
338    #[cfg(feature = "image")]
339    /// Renders a grayscale image buffer.
340    pub fn to_image_buffer(self) -> Result<ImageBuffer<Luma<u8>, Vec<u8>>, RenderError> {
341        let image = self.to_luma8()?;
342
343        let width = u32::try_from(self.width).map_err(|_| RenderError::ImageSizeTooLarge)?;
344        let height = u32::try_from(self.height).map_err(|_| RenderError::ImageSizeTooLarge)?;
345
346        ImageBuffer::from_vec(width, height, image).ok_or(RenderError::ImageSizeTooLarge)
347    }
348
349    fn layout(self) -> Result<Layout, RenderError> {
350        let quiet_zone_modules =
351            self.quiet_zone.checked_mul(2).ok_or(RenderError::ImageSizeTooLarge)?;
352        let modules_width = self
353            .symbol
354            .width()
355            .checked_add(quiet_zone_modules)
356            .ok_or(RenderError::ImageSizeTooLarge)?;
357        let modules_height = self
358            .symbol
359            .height()
360            .checked_add(quiet_zone_modules)
361            .ok_or(RenderError::ImageSizeTooLarge)?;
362
363        // Integer scaling keeps every module edge aligned to an output pixel boundary.
364        let scale = (self.width / modules_width).min(self.height / modules_height);
365
366        if scale == 0 {
367            return Err(RenderError::ImageSizeTooSmall);
368        }
369
370        let symbol_width =
371            self.symbol.width().checked_mul(scale).ok_or(RenderError::ImageSizeTooLarge)?;
372        let symbol_height =
373            self.symbol.height().checked_mul(scale).ok_or(RenderError::ImageSizeTooLarge)?;
374
375        // Centering splits pixels left over after fitting the requested quiet zone and integer scale.
376        Ok(Layout {
377            scale,
378            margin_x: (self.width - symbol_width) / 2,
379            margin_y: (self.height - symbol_height) / 2,
380        })
381    }
382}
383
384/// Draws the symbol as compact Unicode text using half-block characters, with one module per column and two module rows per line.
385///
386/// The alternate form (`{:#}`) inverts dark and light modules for dark terminal backgrounds.
387/// Pixel dimensions are ignored; only the quiet zone setting applies.
388/// Each line ends with a newline.
389///
390/// Note: half-block characters have ambiguous East Asian width and may render as two columns in some CJK terminals, causing misalignment.
391impl fmt::Display for Renderer<'_> {
392    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
393        let visible_dark = !f.alternate();
394        let quiet_zone = self.quiet_zone;
395        let columns = self.symbol.width() + quiet_zone * 2;
396        let rows = self.symbol.height() + quiet_zone * 2;
397
398        for upper in (0..rows).step_by(2) {
399            for x in 0..columns {
400                let visible = |y: usize| {
401                    // Out-of-range lookups return `None`, so the quiet zone reads as light without a boundary check.
402                    let dark = x
403                        .checked_sub(quiet_zone)
404                        .zip(y.checked_sub(quiet_zone))
405                        .and_then(|(x, y)| self.symbol.module(x, y))
406                        == Some(true);
407
408                    dark == visible_dark
409                };
410
411                // The leftover half of an odd final line stays blank in both forms.
412                let lower = upper + 1 < rows && visible(upper + 1);
413
414                f.write_str(match (visible(upper), lower) {
415                    (true, true) => "█",
416                    (true, false) => "▀",
417                    (false, true) => "▄",
418                    (false, false) => " ",
419                })?;
420            }
421
422            f.write_str("\n")?;
423        }
424
425        Ok(())
426    }
427}
428
429#[cfg(feature = "std")]
430struct IoFmtWriter<W> {
431    inner: W,
432    error: Option<io::Error>,
433}
434
435#[cfg(feature = "std")]
436impl<W: IoWrite> fmt::Write for IoFmtWriter<W> {
437    fn write_str(&mut self, text: &str) -> fmt::Result {
438        self.inner.write_all(text.as_bytes()).map_err(|error| {
439            self.error = Some(error);
440            fmt::Error
441        })
442    }
443}
444
445// atomic-write-file has no async backend, so the atomic save runs on tokio's blocking pool.
446#[cfg(feature = "tokio")]
447async fn save_atomic_blocking(path: std::path::PathBuf, bytes: Vec<u8>) -> Result<(), RenderError> {
448    let write = tokio::task::spawn_blocking(move || {
449        let mut file = AtomicWriteFile::open(path)?;
450
451        file.write_all(&bytes)?;
452
453        file.commit()
454    })
455    .await;
456
457    match write {
458        Ok(result) => result.map_err(RenderError::from),
459        Err(join_error) => Err(RenderError::Io(io::Error::other(join_error))),
460    }
461}
462
463struct Layout {
464    scale:    usize,
465    margin_x: usize,
466    margin_y: usize,
467}