1use alloc::{boxed::Box, string::String};
4use qrcode_core::{
5 Color, DynRenderer, ModuleSource, ModuleStorage, PluginError, PluginRegistry, PostProcessor, QrPlugin,
6 RenderConfig, RenderOutput, RendererFactory,
7};
8
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
11pub struct PlainTextRendererPlugin;
12
13impl PlainTextRendererPlugin {
14 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#[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#[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#[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#[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::RenderFailed("quiet_zone does not fit in platform dimensions".into()))?;
136 let border =
137 quiet_zone.checked_mul(2).ok_or_else(|| PluginError::RenderFailed("quiet_zone dimensions overflow".into()))?;
138 let total_width = width
139 .checked_add(border)
140 .ok_or_else(|| PluginError::RenderFailed("plain-text output dimensions overflow".into()))?;
141 let module_end = quiet_zone
142 .checked_add(width)
143 .ok_or_else(|| PluginError::RenderFailed("plain-text module dimensions overflow".into()))?;
144 let dark_modules = code.modules().iter().filter(|&&color| color == Color::Dark).count();
145 let capacity = output_capacity(total_width, dark_modules, dark, light)?;
146 let mut output = String::new();
147 output
148 .try_reserve_exact(capacity)
149 .map_err(|_| PluginError::RenderFailed("could not allocate plain-text output buffer".into()))?;
150
151 for y in 0..total_width {
152 if y > 0 {
153 output.push('\n');
154 }
155
156 let row = (quiet_zone..module_end).contains(&y).then(|| code.row(y - quiet_zone));
157 for x in 0..total_width {
158 let color =
159 row.filter(|_| (quiet_zone..module_end).contains(&x)).map_or(Color::Light, |row| row[x - quiet_zone]);
160 output.push(color.select(dark, light));
161 }
162 }
163
164 Ok(output)
165}
166
167fn output_capacity(width: usize, dark_modules: usize, dark: char, light: char) -> Result<usize, PluginError> {
168 let bytes = width
169 .checked_mul(width)
170 .and_then(|area| area.checked_sub(dark_modules))
171 .and_then(|light_modules| light_modules.checked_mul(light.len_utf8()))
172 .and_then(|light_bytes| {
173 dark_modules.checked_mul(dark.len_utf8()).and_then(|dark_bytes| light_bytes.checked_add(dark_bytes))
174 })
175 .and_then(|bytes| bytes.checked_add(width.saturating_sub(1)))
176 .ok_or_else(|| PluginError::RenderFailed("plain-text output size overflow".into()))?;
177 crate::check_buffer_size(bytes)
178 .map_err(|_| PluginError::RenderFailed("plain-text output exceeds render buffer limits".into()))?;
179 Ok(bytes)
180}
181
182fn config_char(config: &RenderConfig, key: &str, default: char) -> Result<char, PluginError> {
183 let Some(value) = config.option(key) else {
184 return Ok(default);
185 };
186 let mut chars = value.chars();
187 let Some(character) = chars.next() else {
188 return Err(PluginError::InvalidConfig(alloc::format!("{key} must contain exactly one character")));
189 };
190 if chars.next().is_some() {
191 return Err(PluginError::InvalidConfig(alloc::format!("{key} must contain exactly one character")));
192 }
193 Ok(character)
194}
195
196fn config_u32(config: &RenderConfig, key: &str, default: u32) -> Result<u32, PluginError> {
197 config.option(key).map_or(Ok(default), |value| {
198 value.parse().map_err(|_| PluginError::InvalidConfig(alloc::format!("{key} must be a valid u32")))
199 })
200}
201
202#[cfg(test)]
203mod tests {
204 use super::{InvertModulesPlugin, InvertModulesPostProcessor, PlainTextRendererFactory, PlainTextRendererPlugin};
205 use qrcode_core::{
206 Color, ModuleGrid, ModuleSource, PluginError, PluginRegistry, PostProcessor, QrPlugin, RenderConfig,
207 RenderOutput, Renderer as CoreRenderer, RendererFactory,
208 };
209
210 struct BadSource {
211 modules: [Color; 4],
212 }
213
214 impl ModuleSource for BadSource {
215 fn get(&self, x: usize, y: usize) -> Color {
216 self.modules[y * self.width() + x]
217 }
218
219 fn width(&self) -> usize {
220 3
221 }
222
223 fn height(&self) -> usize {
224 2
225 }
226
227 fn modules(&self) -> &[Color] {
228 &self.modules
229 }
230 }
231
232 #[test]
233 fn plugin_registers_plain_text_renderer() {
234 let mut registry = PluginRegistry::new();
235 PlainTextRendererPlugin.register(&mut registry);
236
237 assert!(registry.renderer(PlainTextRendererPlugin::RENDERER_NAME).is_some());
238 }
239
240 #[test]
241 fn invert_modules_plugin_registers_postprocessor() {
242 let mut registry = PluginRegistry::new();
243 InvertModulesPlugin.register(&mut registry);
244
245 assert_eq!(registry.postprocessors().len(), 1);
246 }
247
248 #[test]
249 fn invert_modules_postprocessor_flips_all_modules() {
250 let mut modules =
251 ModuleGrid::new(alloc::vec![Color::Dark, Color::Light, Color::Light, Color::Dark], 2, 2).unwrap();
252
253 InvertModulesPostProcessor.process(&mut modules).unwrap();
254
255 assert_eq!(modules.modules(), [Color::Light, Color::Dark, Color::Dark, Color::Light]);
256 }
257
258 #[test]
259 fn plain_text_renderer_uses_configured_colors_and_quiet_zone() {
260 let modules = ModuleGrid::new(alloc::vec![Color::Dark, Color::Light, Color::Light, Color::Dark], 2, 2).unwrap();
261 let renderer = PlainTextRendererFactory.build(
262 &RenderConfig::new().with_option("dark", "X").with_option("light", ".").with_option("quiet_zone", "0"),
263 );
264
265 assert_eq!(renderer.render(&modules).unwrap(), RenderOutput::Text("X.\n.X".into()));
266 }
267
268 #[test]
269 fn plain_text_core_renderer_matches_dyn_renderer() {
270 let modules = ModuleGrid::new(alloc::vec![Color::Dark, Color::Light, Color::Light, Color::Dark], 2, 2).unwrap();
271 let renderer = super::PlainTextRenderer { dark: 'X', light: '.', quiet_zone: 0, config_valid: true };
272
273 let core_output = CoreRenderer::render(&renderer, &modules).unwrap();
274 let dyn_output = qrcode_core::DynRenderer::render(&renderer, &modules).unwrap();
275
276 assert_eq!(core_output, "X.\n.X");
277 assert_eq!(dyn_output, RenderOutput::Text(core_output));
278 }
279
280 #[test]
281 fn plain_text_renderer_reports_invalid_module_source() {
282 let renderer = PlainTextRendererFactory.build(&RenderConfig::new());
283
284 assert_eq!(renderer.render(&BadSource { modules: [Color::Dark; 4] }), Err(PluginError::InvalidModuleGrid));
285 }
286
287 #[test]
288 fn plain_text_factory_rejects_invalid_configuration_before_building() {
289 let factory = PlainTextRendererFactory;
290
291 assert!(matches!(
292 factory.validate_config(&RenderConfig::new().with_option("quiet_zone", "not-a-number")),
293 Err(PluginError::InvalidConfig(_))
294 ));
295 assert!(matches!(
296 factory.validate_config(&RenderConfig::new().with_option("dark", "XX")),
297 Err(PluginError::InvalidConfig(_))
298 ));
299 }
300
301 #[test]
302 fn plain_text_registry_reports_invalid_configuration() {
303 let mut registry = PluginRegistry::new();
304 PlainTextRendererPlugin.register(&mut registry);
305
306 assert!(matches!(
307 registry.build_renderer(
308 PlainTextRendererPlugin::RENDERER_NAME,
309 &RenderConfig::new().with_option("quiet_zone", "-1")
310 ),
311 Err(PluginError::InvalidConfig(_))
312 ));
313 }
314
315 #[test]
316 fn plain_text_renderer_checks_dimensions_before_arithmetic() {
317 let renderer = super::PlainTextRenderer { dark: 'X', light: '.', quiet_zone: u32::MAX, config_valid: true };
318 let modules = ModuleGrid::new(alloc::vec![Color::Dark], 1, 1).unwrap();
319
320 assert!(matches!(CoreRenderer::render(&renderer, &modules), Err(PluginError::RenderFailed(_))));
321 }
322
323 #[test]
324 fn plain_text_capacity_counts_utf8_modules_quiet_zone_and_newlines() {
325 assert_eq!(super::output_capacity(4, 2, '💖', '.'), Ok(2 * 4 + 14 + 3));
326 assert_eq!(super::output_capacity(4, 2, '#', '💖'), Ok(2 + 14 * 4 + 3));
327 assert_eq!(super::output_capacity(1, 1, '💖', '.'), Ok(4));
328 assert!(matches!(super::output_capacity(usize::MAX, 1, '#', '.'), Err(PluginError::RenderFailed(_))));
329 assert!(matches!(super::output_capacity(16_385, 1, '#', '.'), Err(PluginError::RenderFailed(_))));
330 assert!(matches!(super::output_capacity(10_001, 1, '💖', '🙂'), Err(PluginError::RenderFailed(_))));
331 let capacity = super::output_capacity(16_383, 1, '#', '.').unwrap();
332 assert!(capacity <= crate::MAX_BUFFER_BYTES);
333 }
334
335 #[test]
336 fn plain_text_plugin_rejects_output_budget_before_allocation() {
337 let source = ModuleGrid::new(alloc::vec![Color::Dark], 1, 1).unwrap();
338 for config in [
339 RenderConfig::new().with_option("quiet_zone", alloc::format!("{}", u32::MAX / 2)),
340 RenderConfig::new().with_option("quiet_zone", "8192"),
341 RenderConfig::new().with_option("quiet_zone", "5000").with_option("dark", "💖").with_option("light", "🙂"),
342 ] {
343 let renderer = PlainTextRendererFactory.build(&config);
344 assert!(matches!(renderer.render(&source), Err(PluginError::RenderFailed(_))));
345 }
346 }
347
348 #[test]
349 fn plain_text_plugin_keeps_configuration_grid_and_output_error_order() {
350 let source = BadSource { modules: [Color::Dark; 4] };
351 let invalid = PlainTextRendererFactory.build(&RenderConfig::new().with_option("quiet_zone", "not-a-number"));
352 assert!(matches!(invalid.render(&source), Err(PluginError::InvalidConfig(_))));
353 let oversized = PlainTextRendererFactory.build(&RenderConfig::new().with_option("quiet_zone", "8192"));
354 assert_eq!(oversized.render(&source), Err(PluginError::InvalidModuleGrid));
355 }
356
357 #[test]
358 fn plain_text_plugin_preserves_unicode_output_for_core_and_dyn_renderers() {
359 let source = ModuleGrid::new(alloc::vec![Color::Dark, Color::Light, Color::Light, Color::Dark], 2, 2).unwrap();
360 let renderer = super::PlainTextRenderer { dark: '💖', light: '界', quiet_zone: 1, config_valid: true };
361 let expected = "界界界界\n界💖界界\n界界💖界\n界界界界";
362 assert_eq!(CoreRenderer::render(&renderer, &source).unwrap(), expected);
363 assert_eq!(qrcode_core::DynRenderer::render(&renderer, &source).unwrap(), RenderOutput::Text(expected.into()));
364 }
365}