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 width = u32::try_from(self.width).map_err(|_| RenderError::ImageSizeTooLarge)?;
282        let height = u32::try_from(self.height).map_err(|_| RenderError::ImageSizeTooLarge)?;
283        let image = self.to_luma8()?;
284
285        PngEncoder::new_with_quality(writer, CompressionType::Best, FilterType::NoFilter)
286            .write_image(&image, width, height, ColorType::L8.into())?;
287
288        Ok(())
289    }
290
291    #[cfg(feature = "image")]
292    /// Renders a grayscale PNG image into a byte vector.
293    pub fn to_png_vec(self) -> Result<Vec<u8>, RenderError> {
294        let mut bytes = Vec::with_capacity(4096);
295
296        self.write_png(&mut bytes)?;
297
298        Ok(bytes)
299    }
300
301    #[cfg(all(feature = "tokio", feature = "image"))]
302    /// Renders a PNG image in memory, then writes and flushes it to a tokio asynchronous writer.
303    pub async fn write_png_async<W: TokioAsyncWrite + Unpin>(
304        self,
305        mut writer: W,
306    ) -> Result<(), RenderError> {
307        let png = self.to_png_vec()?;
308
309        writer.write_all(&png).await?;
310        writer.flush().await?;
311
312        Ok(())
313    }
314
315    #[cfg(feature = "image")]
316    /// Atomically saves a PNG image after rendering succeeds.
317    pub fn save_png(self, path: impl AsRef<Path>) -> Result<(), RenderError> {
318        let mut file = AtomicWriteFile::open(path)?;
319
320        self.write_png(&mut file)?;
321
322        file.commit()?;
323
324        Ok(())
325    }
326
327    #[cfg(all(feature = "tokio", feature = "image"))]
328    /// Atomically saves a PNG image after rendering succeeds, offloading the write to tokio's blocking pool.
329    ///
330    /// Like [`save_png`](Self::save_png), the write goes through a temporary file, so an existing file is left untouched if it fails.
331    pub async fn save_png_async(self, path: impl AsRef<Path>) -> Result<(), RenderError> {
332        let png = self.to_png_vec()?;
333
334        save_atomic_blocking(path.as_ref().to_path_buf(), png).await
335    }
336
337    #[cfg(feature = "image")]
338    /// Renders a grayscale image buffer.
339    pub fn to_image_buffer(self) -> Result<ImageBuffer<Luma<u8>, Vec<u8>>, RenderError> {
340        let width = u32::try_from(self.width).map_err(|_| RenderError::ImageSizeTooLarge)?;
341        let height = u32::try_from(self.height).map_err(|_| RenderError::ImageSizeTooLarge)?;
342        let image = self.to_luma8()?;
343
344        ImageBuffer::from_vec(width, height, image).ok_or(RenderError::ImageSizeTooLarge)
345    }
346
347    fn layout(self) -> Result<Layout, RenderError> {
348        let quiet_zone_modules =
349            self.quiet_zone.checked_mul(2).ok_or(RenderError::ImageSizeTooLarge)?;
350        let modules_width = self
351            .symbol
352            .width()
353            .checked_add(quiet_zone_modules)
354            .ok_or(RenderError::ImageSizeTooLarge)?;
355        let modules_height = self
356            .symbol
357            .height()
358            .checked_add(quiet_zone_modules)
359            .ok_or(RenderError::ImageSizeTooLarge)?;
360
361        // Integer scaling keeps every module edge aligned to an output pixel boundary.
362        let scale = (self.width / modules_width).min(self.height / modules_height);
363
364        if scale == 0 {
365            return Err(RenderError::ImageSizeTooSmall);
366        }
367
368        let symbol_width =
369            self.symbol.width().checked_mul(scale).ok_or(RenderError::ImageSizeTooLarge)?;
370        let symbol_height =
371            self.symbol.height().checked_mul(scale).ok_or(RenderError::ImageSizeTooLarge)?;
372
373        // Centering splits pixels left over after fitting the requested quiet zone and integer scale.
374        Ok(Layout {
375            scale,
376            margin_x: (self.width - symbol_width) / 2,
377            margin_y: (self.height - symbol_height) / 2,
378        })
379    }
380}
381
382/// Draws the symbol as compact Unicode text using half-block characters, with one module per column and two module rows per line.
383///
384/// The alternate form (`{:#}`) inverts dark and light modules for dark terminal backgrounds.
385/// Pixel dimensions are ignored; only the quiet zone setting applies.
386/// Each line ends with a newline.
387///
388/// Note: half-block characters have ambiguous East Asian width and may render as two columns in some CJK terminals, causing misalignment.
389impl fmt::Display for Renderer<'_> {
390    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
391        let visible_dark = !f.alternate();
392        let quiet_zone = self.quiet_zone;
393        let columns = self.symbol.width() + quiet_zone * 2;
394        let rows = self.symbol.height() + quiet_zone * 2;
395
396        for upper in (0..rows).step_by(2) {
397            for x in 0..columns {
398                let visible = |y: usize| {
399                    // Out-of-range lookups return `None`, so the quiet zone reads as light without a boundary check.
400                    let dark = x
401                        .checked_sub(quiet_zone)
402                        .zip(y.checked_sub(quiet_zone))
403                        .and_then(|(x, y)| self.symbol.module(x, y))
404                        == Some(true);
405
406                    dark == visible_dark
407                };
408
409                // The leftover half of an odd final line stays blank in both forms.
410                let lower = upper + 1 < rows && visible(upper + 1);
411
412                f.write_str(match (visible(upper), lower) {
413                    (true, true) => "█",
414                    (true, false) => "▀",
415                    (false, true) => "▄",
416                    (false, false) => " ",
417                })?;
418            }
419
420            f.write_str("\n")?;
421        }
422
423        Ok(())
424    }
425}
426
427#[cfg(feature = "std")]
428struct IoFmtWriter<W> {
429    inner: W,
430    error: Option<io::Error>,
431}
432
433#[cfg(feature = "std")]
434impl<W: IoWrite> fmt::Write for IoFmtWriter<W> {
435    fn write_str(&mut self, text: &str) -> fmt::Result {
436        self.inner.write_all(text.as_bytes()).map_err(|error| {
437            self.error = Some(error);
438            fmt::Error
439        })
440    }
441}
442
443// atomic-write-file has no async backend, so the atomic save runs on tokio's blocking pool.
444#[cfg(feature = "tokio")]
445async fn save_atomic_blocking(path: std::path::PathBuf, bytes: Vec<u8>) -> Result<(), RenderError> {
446    let write = tokio::task::spawn_blocking(move || {
447        let mut file = AtomicWriteFile::open(path)?;
448
449        file.write_all(&bytes)?;
450
451        file.commit()
452    })
453    .await;
454
455    match write {
456        Ok(result) => result.map_err(RenderError::from),
457        Err(join_error) => Err(RenderError::Io(io::Error::other(join_error))),
458    }
459}
460
461struct Layout {
462    scale:    usize,
463    margin_x: usize,
464    margin_y: usize,
465}