1use std::collections::HashMap;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub enum Position {
13 Top,
14 Bottom,
15 Left,
16 Right,
17}
18
19#[derive(Debug, Clone, PartialEq)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22pub enum DecorationType {
23 Text(String),
25 Image(String),
27}
28
29#[derive(Debug, Clone, PartialEq)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32pub struct BorderOptions {
33 pub thickness: f64,
35 pub color: String,
37 pub dasharray: Option<String>,
39}
40
41impl Default for BorderOptions {
42 fn default() -> Self {
43 Self {
44 thickness: 10.0,
45 color: "#000000".to_string(),
46 dasharray: None,
47 }
48 }
49}
50
51impl BorderOptions {
52 pub fn new(thickness: f64, color: impl Into<String>) -> Self {
54 Self {
55 thickness,
56 color: color.into(),
57 dasharray: None,
58 }
59 }
60
61 pub fn with_dasharray(mut self, dasharray: impl Into<String>) -> Self {
63 self.dasharray = Some(dasharray.into());
64 self
65 }
66}
67
68#[derive(Debug, Clone, PartialEq)]
70#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
71pub struct BorderDecoration {
72 pub decoration_type: DecorationType,
74 pub style: Option<String>,
76}
77
78impl BorderDecoration {
79 pub fn text(value: impl Into<String>) -> Self {
81 Self {
82 decoration_type: DecorationType::Text(value.into()),
83 style: None,
84 }
85 }
86
87 pub fn image(value: impl Into<String>) -> Self {
89 Self {
90 decoration_type: DecorationType::Image(value.into()),
91 style: None,
92 }
93 }
94
95 pub fn with_style(mut self, style: impl Into<String>) -> Self {
97 self.style = Some(style.into());
98 self
99 }
100}
101
102#[derive(Debug, Clone, PartialEq)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105pub struct QRBorderOptions {
106 pub border: BorderOptions,
108 pub round: f64,
110 pub border_inner: Option<BorderOptions>,
112 pub border_outer: Option<BorderOptions>,
114 pub decorations: HashMap<Position, BorderDecoration>,
116}
117
118impl Default for QRBorderOptions {
119 fn default() -> Self {
120 Self {
121 border: BorderOptions::default(),
122 round: 0.0,
123 border_inner: None,
124 border_outer: None,
125 decorations: HashMap::new(),
126 }
127 }
128}
129
130impl QRBorderOptions {
131 pub fn new(thickness: f64, color: impl Into<String>) -> Self {
133 Self {
134 border: BorderOptions::new(thickness, color),
135 ..Default::default()
136 }
137 }
138
139 pub fn with_round(mut self, round: f64) -> Self {
141 self.round = round.clamp(0.0, 1.0);
142 self
143 }
144
145 pub fn with_inner_border(mut self, options: BorderOptions) -> Self {
147 self.border_inner = Some(options);
148 self
149 }
150
151 pub fn with_outer_border(mut self, options: BorderOptions) -> Self {
153 self.border_outer = Some(options);
154 self
155 }
156
157 pub fn with_decoration(mut self, position: Position, decoration: BorderDecoration) -> Self {
159 self.decorations.insert(position, decoration);
160 self
161 }
162
163 pub fn with_text(mut self, position: Position, text: impl Into<String>) -> Self {
165 self.decorations
166 .insert(position, BorderDecoration::text(text));
167 self
168 }
169
170 pub fn with_styled_text(
172 mut self,
173 position: Position,
174 text: impl Into<String>,
175 style: impl Into<String>,
176 ) -> Self {
177 self.decorations
178 .insert(position, BorderDecoration::text(text).with_style(style));
179 self
180 }
181}
182
183pub struct BorderPlugin {
185 options: QRBorderOptions,
186}
187
188impl BorderPlugin {
189 pub fn new(options: QRBorderOptions) -> Self {
191 Self { options }
192 }
193
194 pub fn apply(&self, svg: &str, width: u32, height: u32) -> String {
197 let width = width as f64;
198 let height = height as f64;
199
200 let mut defs_content = String::new();
201 let mut elements_content = String::new();
202
203 let main_attrs = self.generate_rect_attributes(width, height, &self.options.border);
205 elements_content.push_str(&self.create_rect(&main_attrs));
206
207 if let Some(ref inner) = self.options.border_inner {
209 let mut inner_attrs = self.generate_rect_attributes(width, height, inner);
210
211 inner_attrs.x =
213 inner_attrs.x - inner.thickness + self.options.border.thickness;
214 inner_attrs.y =
215 inner_attrs.y - inner.thickness + self.options.border.thickness;
216 inner_attrs.width =
217 inner_attrs.width + 2.0 * (inner.thickness - self.options.border.thickness);
218 inner_attrs.height =
219 inner_attrs.height + 2.0 * (inner.thickness - self.options.border.thickness);
220 inner_attrs.rx = (inner_attrs.rx + inner.thickness - self.options.border.thickness)
221 .max(0.0);
222
223 elements_content.push_str(&self.create_rect(&inner_attrs));
224 }
225
226 if let Some(ref outer) = self.options.border_outer {
228 let outer_attrs = self.generate_rect_attributes(width, height, outer);
229 elements_content.push_str(&self.create_rect(&outer_attrs));
230 }
231
232 for (position, decoration) in &self.options.decorations {
234 match &decoration.decoration_type {
235 DecorationType::Text(text) => {
236 let (path_def, text_elem) = self.create_text_decoration(
237 *position,
238 text,
239 decoration.style.as_deref(),
240 width,
241 height,
242 );
243 defs_content.push_str(&path_def);
244 elements_content.push_str(&text_elem);
245 }
246 DecorationType::Image(src) => {
247 let image_elem = self.create_image_decoration(
248 *position,
249 src,
250 decoration.style.as_deref(),
251 width,
252 height,
253 );
254 elements_content.push_str(&image_elem);
255 }
256 }
257 }
258
259 self.inject_into_svg(svg, &defs_content, &elements_content)
261 }
262
263 fn generate_rect_attributes(&self, width: f64, height: f64, options: &BorderOptions) -> RectAttributes {
264 let size = width.min(height);
265 let rx = ((size / 2.0) * self.options.round - options.thickness / 2.0).max(0.0);
266
267 RectAttributes {
268 fill: "none".to_string(),
269 x: (width - size + options.thickness) / 2.0,
270 y: (height - size + options.thickness) / 2.0,
271 width: size - options.thickness,
272 height: size - options.thickness,
273 stroke: options.color.clone(),
274 stroke_width: options.thickness,
275 stroke_dasharray: options.dasharray.clone().unwrap_or_default(),
276 rx,
277 }
278 }
279
280 fn create_rect(&self, attrs: &RectAttributes) -> String {
281 let dasharray_attr = if attrs.stroke_dasharray.is_empty() {
282 String::new()
283 } else {
284 format!(r#" stroke-dasharray="{}""#, attrs.stroke_dasharray)
285 };
286
287 format!(
288 r#"<rect fill="{}" x="{}" y="{}" width="{}" height="{}" stroke="{}" stroke-width="{}"{} rx="{}"/>
289"#,
290 attrs.fill,
291 attrs.x,
292 attrs.y,
293 attrs.width,
294 attrs.height,
295 attrs.stroke,
296 attrs.stroke_width,
297 dasharray_attr,
298 attrs.rx
299 )
300 }
301
302 fn create_text_decoration(
303 &self,
304 position: Position,
305 text: &str,
306 style: Option<&str>,
307 width: f64,
308 height: f64,
309 ) -> (String, String) {
310 let thickness = self.options.border.thickness;
311 let round = self.options.round;
312 let size = width.min(height);
313
314 let cx = width / 2.0;
316 let cy = height / 2.0;
317
318 let text_radius = (size - thickness) / 2.0;
320
321 let path_id = format!("{:?}-text-path", position).to_lowercase();
322
323 let base_style = style.unwrap_or("font-size: 14px; font-family: Arial, sans-serif;");
325
326 if round >= 0.5 {
328 let path_d = match position {
330 Position::Top => {
331 format!(
333 "M {},{} A {},{} 0 0 1 {},{}",
334 cx - text_radius, cy,
335 text_radius, text_radius,
336 cx + text_radius, cy
337 )
338 }
339 Position::Bottom => {
340 format!(
342 "M {},{} A {},{} 0 0 0 {},{}",
343 cx - text_radius, cy,
344 text_radius, text_radius,
345 cx + text_radius, cy
346 )
347 }
348 Position::Left => {
349 format!(
351 "M {},{} A {},{} 0 0 0 {},{}",
352 cx, cy - text_radius,
353 text_radius, text_radius,
354 cx, cy + text_radius
355 )
356 }
357 Position::Right => {
358 format!(
360 "M {},{} A {},{} 0 0 1 {},{}",
361 cx, cy - text_radius,
362 text_radius, text_radius,
363 cx, cy + text_radius
364 )
365 }
366 };
367
368 let path_def = format!(
369 "<path id=\"{}\" d=\"{}\" fill=\"none\"/>\n",
370 path_id, path_d
371 );
372
373 let text_elem = format!(
374 "<text style=\"{}\">\n <textPath xlink:href=\"#{}\" href=\"#{}\" startOffset=\"50%\" text-anchor=\"middle\" dominant-baseline=\"central\">{}</textPath>\n</text>\n",
375 base_style, path_id, path_id, text
376 );
377
378 (path_def, text_elem)
379 } else {
380 let border_offset = thickness / 2.0;
382 let half_size = (size - thickness) / 2.0;
383
384 let (x, y, rotation) = match position {
385 Position::Top => (cx, cy - half_size - border_offset, 0.0),
386 Position::Bottom => (cx, cy + half_size + border_offset, 0.0),
387 Position::Left => (cx - half_size - border_offset, cy, -90.0),
388 Position::Right => (cx + half_size + border_offset, cy, 90.0),
389 };
390
391 let transform = if rotation != 0.0 {
392 format!(r#" transform="rotate({},{},{})""#, rotation, x, y)
393 } else {
394 String::new()
395 };
396
397 let text_elem = format!(
398 r#"<text x="{}" y="{}" text-anchor="middle" dominant-baseline="middle" style="{}"{}>{}</text>
399"#,
400 x, y, base_style, transform, text
401 );
402
403 (String::new(), text_elem)
404 }
405 }
406
407 fn create_image_decoration(
408 &self,
409 position: Position,
410 src: &str,
411 style: Option<&str>,
412 width: f64,
413 height: f64,
414 ) -> String {
415 let thickness = self.options.border.thickness;
416 let size = width.min(height);
417
418 let mut x = (width - size + thickness) / 2.0;
419 let mut y = (height - size + thickness) / 2.0;
420
421 match position {
422 Position::Top => {
423 x += (size - thickness) / 2.0;
424 }
425 Position::Right => {
426 x += size - thickness;
427 y += (size - thickness) / 2.0;
428 }
429 Position::Bottom => {
430 x += (size - thickness) / 2.0;
431 y += size - thickness;
432 }
433 Position::Left => {
434 y += (size - thickness) / 2.0;
435 }
436 }
437
438 let style_attr = style
439 .map(|s| format!(r#" style="{}""#, s))
440 .unwrap_or_default();
441
442 format!(
443 r#"<image href="{}" xlink:href="{}" x="{}" y="{}"{}/>"#,
444 src, src, x, y, style_attr
445 )
446 }
447
448 fn inject_into_svg(&self, svg: &str, defs_content: &str, elements_content: &str) -> String {
449 if let Some(close_pos) = svg.rfind("</svg>") {
451 let mut result = String::with_capacity(svg.len() + defs_content.len() + elements_content.len() + 100);
452 result.push_str(&svg[..close_pos]);
453
454 if !defs_content.is_empty() {
456 if let Some(defs_close) = svg.find("</defs>") {
458 let before_defs_close = &svg[..defs_close];
460 let after_defs_close = &svg[defs_close..close_pos];
461 result.clear();
462 result.push_str(before_defs_close);
463 result.push_str(defs_content);
464 result.push_str(after_defs_close);
465 } else {
466 result.push_str("<defs>\n");
468 result.push_str(defs_content);
469 result.push_str("</defs>\n");
470 }
471 }
472
473 result.push_str(elements_content);
474 result.push_str("</svg>");
475 result
476 } else {
477 format!("{}\n{}", svg.trim_end(), elements_content)
479 }
480 }
481}
482
483struct RectAttributes {
485 fill: String,
486 x: f64,
487 y: f64,
488 width: f64,
489 height: f64,
490 stroke: String,
491 stroke_width: f64,
492 stroke_dasharray: String,
493 rx: f64,
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
501 fn test_border_options_default() {
502 let opts = BorderOptions::default();
503 assert_eq!(opts.thickness, 10.0);
504 assert_eq!(opts.color, "#000000");
505 assert!(opts.dasharray.is_none());
506 }
507
508 #[test]
509 fn test_qr_border_options_builder() {
510 let opts = QRBorderOptions::new(15.0, "#FF0000")
511 .with_round(0.5)
512 .with_text(Position::Top, "SCAN ME")
513 .with_inner_border(BorderOptions::new(5.0, "#00FF00"));
514
515 assert_eq!(opts.border.thickness, 15.0);
516 assert_eq!(opts.border.color, "#FF0000");
517 assert_eq!(opts.round, 0.5);
518 assert!(opts.border_inner.is_some());
519 assert!(opts.decorations.contains_key(&Position::Top));
520 }
521
522 #[test]
523 fn test_border_plugin_apply() {
524 let svg = r#"<?xml version="1.0"?>
525<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300">
526<defs></defs>
527<rect x="0" y="0" width="300" height="300" fill="white"/>
528</svg>"#;
529
530 let options = QRBorderOptions::new(10.0, "#000000").with_round(0.2);
531 let plugin = BorderPlugin::new(options);
532 let result = plugin.apply(svg, 300, 300);
533
534 assert!(result.contains("stroke=\"#000000\""));
535 assert!(result.contains("stroke-width=\"10\""));
536 }
537
538 #[test]
539 fn test_border_with_text_decoration() {
540 let svg = r#"<?xml version="1.0"?>
541<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300">
542<defs></defs>
543</svg>"#;
544
545 let options = QRBorderOptions::new(20.0, "#333333")
547 .with_round(0.5)
548 .with_styled_text(Position::Top, "SCAN ME", "font-size: 14px; fill: #333;");
549
550 let plugin = BorderPlugin::new(options);
551 let result = plugin.apply(svg, 300, 300);
552
553 assert!(result.contains("SCAN ME"));
554 assert!(result.contains("textPath"));
555 assert!(result.contains("top-text-path"));
556 }
557}