Skip to main content

qrcode_render/
plugin.rs

1//! Built-in renderer plugins backed by `qrcode-render`.
2
3use alloc::{boxed::Box, string::String};
4use qrcode_core::{
5    Color, DynRenderer, ModuleSource, ModuleStorage, PluginError, PluginRegistry, PostProcessor, QrPlugin,
6    RenderConfig, RenderOutput, RendererFactory,
7};
8
9/// Built-in plugin that registers the plain-text renderer.
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
11pub struct PlainTextRendererPlugin;
12
13impl PlainTextRendererPlugin {
14    /// Renderer name registered by this plugin.
15    pub const RENDERER_NAME: &'static str = "plain-text";
16}
17
18impl QrPlugin for PlainTextRendererPlugin {
19    fn name(&self) -> &str {
20        "qrcode-render/plain-text"
21    }
22
23    fn version(&self) -> &str {
24        env!("CARGO_PKG_VERSION")
25    }
26
27    fn register(&self, registry: &mut PluginRegistry) {
28        registry.register_renderer(Self::RENDERER_NAME, Box::new(PlainTextRendererFactory));
29    }
30}
31
32/// Built-in plugin that registers [`InvertModulesPostProcessor`].
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub struct InvertModulesPlugin;
35
36impl QrPlugin for InvertModulesPlugin {
37    fn name(&self) -> &str {
38        "qrcode-render/invert-modules"
39    }
40
41    fn version(&self) -> &str {
42        env!("CARGO_PKG_VERSION")
43    }
44
45    fn register(&self, registry: &mut PluginRegistry) {
46        registry.register_postprocessor(Box::new(InvertModulesPostProcessor));
47    }
48}
49
50/// Postprocessor that flips every module from dark to light, or light to dark.
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
52pub struct InvertModulesPostProcessor;
53
54impl PostProcessor for InvertModulesPostProcessor {
55    fn process(&self, modules: &mut dyn ModuleStorage) -> Result<(), PluginError> {
56        for y in 0..modules.height() {
57            for x in 0..modules.width() {
58                modules.set(x, y, !modules.get(x, y));
59            }
60        }
61        Ok(())
62    }
63}
64
65/// Factory for [`PlainTextRenderer`].
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
67pub struct PlainTextRendererFactory;
68
69impl RendererFactory for PlainTextRendererFactory {
70    fn build(&self, config: &RenderConfig) -> Box<dyn DynRenderer> {
71        let config_valid = self.validate_config(config).is_ok();
72        let dark = config_char(config, "dark", '#').unwrap_or('#');
73        let light = config_char(config, "light", ' ').unwrap_or(' ');
74        let quiet_zone = config_u32(config, "quiet_zone", 4).unwrap_or(4);
75        Box::new(PlainTextRenderer { dark, light, quiet_zone, config_valid })
76    }
77
78    fn validate_config(&self, config: &RenderConfig) -> Result<(), PluginError> {
79        config_char(config, "dark", '#')?;
80        config_char(config, "light", ' ')?;
81        config_u32(config, "quiet_zone", 4)?;
82        Ok(())
83    }
84}
85
86/// Object-safe plain-text renderer.
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub struct PlainTextRenderer {
89    dark: char,
90    light: char,
91    quiet_zone: u32,
92    config_valid: bool,
93}
94
95impl DynRenderer for PlainTextRenderer {
96    fn render(&self, code: &dyn ModuleSource) -> Result<RenderOutput, PluginError> {
97        qrcode_core::Renderer::render(self, code).map(RenderOutput::Text)
98    }
99}
100
101impl<Code> qrcode_core::Renderer<Code> for PlainTextRenderer
102where
103    Code: ModuleSource + ?Sized,
104{
105    type Output = String;
106    type Error = PluginError;
107
108    fn render(&self, code: &Code) -> Result<Self::Output, Self::Error> {
109        if !self.config_valid {
110            return Err(PluginError::InvalidConfig("plain-text renderer configuration is invalid".into()));
111        }
112        validate_module_source(code)?;
113        render_plain_text(code, self.dark, self.light, self.quiet_zone)
114    }
115}
116
117fn validate_module_source<Code>(code: &Code) -> Result<(), PluginError>
118where
119    Code: ModuleSource + ?Sized,
120{
121    let width = code.width();
122    let height = code.height();
123    match width.checked_mul(height) {
124        Some(len) if width != 0 && width == height && len == code.modules().len() => Ok(()),
125        _ => Err(PluginError::InvalidModuleGrid),
126    }
127}
128
129fn render_plain_text<Code>(code: &Code, dark: char, light: char, quiet_zone: u32) -> Result<String, PluginError>
130where
131    Code: ModuleSource + ?Sized,
132{
133    let width = code.width();
134    let quiet_zone = usize::try_from(quiet_zone)
135        .map_err(|_| PluginError::InvalidConfig("quiet_zone does not fit in platform dimensions".into()))?;
136    let border =
137        quiet_zone.checked_mul(2).ok_or_else(|| PluginError::InvalidConfig("quiet_zone dimensions overflow".into()))?;
138    let total_width = width
139        .checked_add(border)
140        .ok_or_else(|| PluginError::InvalidConfig("plain-text output dimensions overflow".into()))?;
141    let module_end = quiet_zone
142        .checked_add(width)
143        .ok_or_else(|| PluginError::InvalidConfig("plain-text module dimensions overflow".into()))?;
144    let capacity = total_width
145        .checked_mul(total_width)
146        .and_then(|area| area.checked_add(total_width.saturating_sub(1)))
147        .ok_or_else(|| PluginError::InvalidConfig("plain-text output size overflow".into()))?;
148    let mut output = String::with_capacity(capacity);
149
150    for y in 0..total_width {
151        if y > 0 {
152            output.push('\n');
153        }
154
155        let row = (quiet_zone..module_end).contains(&y).then(|| code.row(y - quiet_zone));
156        for x in 0..total_width {
157            let color =
158                row.filter(|_| (quiet_zone..module_end).contains(&x)).map_or(Color::Light, |row| row[x - quiet_zone]);
159            output.push(color.select(dark, light));
160        }
161    }
162
163    Ok(output)
164}
165
166fn config_char(config: &RenderConfig, key: &str, default: char) -> Result<char, PluginError> {
167    let Some(value) = config.option(key) else {
168        return Ok(default);
169    };
170    let mut chars = value.chars();
171    let Some(character) = chars.next() else {
172        return Err(PluginError::InvalidConfig(alloc::format!("{key} must contain exactly one character")));
173    };
174    if chars.next().is_some() {
175        return Err(PluginError::InvalidConfig(alloc::format!("{key} must contain exactly one character")));
176    }
177    Ok(character)
178}
179
180fn config_u32(config: &RenderConfig, key: &str, default: u32) -> Result<u32, PluginError> {
181    config.option(key).map_or(Ok(default), |value| {
182        value.parse().map_err(|_| PluginError::InvalidConfig(alloc::format!("{key} must be a valid u32")))
183    })
184}
185
186#[cfg(test)]
187mod tests {
188    use super::{InvertModulesPlugin, InvertModulesPostProcessor, PlainTextRendererFactory, PlainTextRendererPlugin};
189    use qrcode_core::{
190        Color, ModuleGrid, ModuleSource, PluginError, PluginRegistry, PostProcessor, QrPlugin, RenderConfig,
191        RenderOutput, Renderer as CoreRenderer, RendererFactory,
192    };
193
194    struct BadSource {
195        modules: [Color; 4],
196    }
197
198    impl ModuleSource for BadSource {
199        fn get(&self, x: usize, y: usize) -> Color {
200            self.modules[y * self.width() + x]
201        }
202
203        fn width(&self) -> usize {
204            3
205        }
206
207        fn height(&self) -> usize {
208            2
209        }
210
211        fn modules(&self) -> &[Color] {
212            &self.modules
213        }
214    }
215
216    #[test]
217    fn plugin_registers_plain_text_renderer() {
218        let mut registry = PluginRegistry::new();
219        PlainTextRendererPlugin.register(&mut registry);
220
221        assert!(registry.renderer(PlainTextRendererPlugin::RENDERER_NAME).is_some());
222    }
223
224    #[test]
225    fn invert_modules_plugin_registers_postprocessor() {
226        let mut registry = PluginRegistry::new();
227        InvertModulesPlugin.register(&mut registry);
228
229        assert_eq!(registry.postprocessors().len(), 1);
230    }
231
232    #[test]
233    fn invert_modules_postprocessor_flips_all_modules() {
234        let mut modules =
235            ModuleGrid::new(alloc::vec![Color::Dark, Color::Light, Color::Light, Color::Dark], 2, 2).unwrap();
236
237        InvertModulesPostProcessor.process(&mut modules).unwrap();
238
239        assert_eq!(modules.modules(), [Color::Light, Color::Dark, Color::Dark, Color::Light]);
240    }
241
242    #[test]
243    fn plain_text_renderer_uses_configured_colors_and_quiet_zone() {
244        let modules = ModuleGrid::new(alloc::vec![Color::Dark, Color::Light, Color::Light, Color::Dark], 2, 2).unwrap();
245        let renderer = PlainTextRendererFactory.build(
246            &RenderConfig::new().with_option("dark", "X").with_option("light", ".").with_option("quiet_zone", "0"),
247        );
248
249        assert_eq!(renderer.render(&modules).unwrap(), RenderOutput::Text("X.\n.X".into()));
250    }
251
252    #[test]
253    fn plain_text_core_renderer_matches_dyn_renderer() {
254        let modules = ModuleGrid::new(alloc::vec![Color::Dark, Color::Light, Color::Light, Color::Dark], 2, 2).unwrap();
255        let renderer = super::PlainTextRenderer { dark: 'X', light: '.', quiet_zone: 0, config_valid: true };
256
257        let core_output = CoreRenderer::render(&renderer, &modules).unwrap();
258        let dyn_output = qrcode_core::DynRenderer::render(&renderer, &modules).unwrap();
259
260        assert_eq!(core_output, "X.\n.X");
261        assert_eq!(dyn_output, RenderOutput::Text(core_output));
262    }
263
264    #[test]
265    fn plain_text_renderer_reports_invalid_module_source() {
266        let renderer = PlainTextRendererFactory.build(&RenderConfig::new());
267
268        assert_eq!(renderer.render(&BadSource { modules: [Color::Dark; 4] }), Err(PluginError::InvalidModuleGrid));
269    }
270
271    #[test]
272    fn plain_text_factory_rejects_invalid_configuration_before_building() {
273        let factory = PlainTextRendererFactory;
274
275        assert!(matches!(
276            factory.validate_config(&RenderConfig::new().with_option("quiet_zone", "not-a-number")),
277            Err(PluginError::InvalidConfig(_))
278        ));
279        assert!(matches!(
280            factory.validate_config(&RenderConfig::new().with_option("dark", "XX")),
281            Err(PluginError::InvalidConfig(_))
282        ));
283    }
284
285    #[test]
286    fn plain_text_registry_reports_invalid_configuration() {
287        let mut registry = PluginRegistry::new();
288        PlainTextRendererPlugin.register(&mut registry);
289
290        assert!(matches!(
291            registry.build_renderer(
292                PlainTextRendererPlugin::RENDERER_NAME,
293                &RenderConfig::new().with_option("quiet_zone", "-1")
294            ),
295            Err(PluginError::InvalidConfig(_))
296        ));
297    }
298
299    #[test]
300    fn plain_text_renderer_checks_dimensions_before_arithmetic() {
301        let renderer = super::PlainTextRenderer { dark: 'X', light: '.', quiet_zone: u32::MAX, config_valid: true };
302        let modules = ModuleGrid::new(alloc::vec![Color::Dark], 1, 1).unwrap();
303
304        assert!(matches!(CoreRenderer::render(&renderer, &modules), Err(PluginError::InvalidConfig(_))));
305    }
306}