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