Skip to main content

browser_automation_cli/
qr_local.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! One-shot QR encode/decode (no Chrome, pure-Rust path).
3//!
4//! # Workload
5//!
6//! **CPU-light** single payload. Encode builds one matrix; decode usually finds
7//! one grid. Multi-grid decode loops are O(grids) with grids typically 1 —
8//! coordination overhead of Rayon exceeds gain (rules: never parallelize when
9//! cost ≪ spawn). Sequential is intentional, not an omission.
10
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use image::Luma;
15use qrcode::QrCode;
16use serde_json::{json, Value};
17
18use crate::error::{CliError, ErrorKind};
19use crate::xdg;
20
21/// Encode text to PNG/SVG/terminal and optional path.
22pub fn encode(text: &str, format: &str, path: Option<&Path>) -> Result<Value, CliError> {
23    if text.is_empty() {
24        return Err(CliError::new(ErrorKind::Usage, "qr encode requires --text"));
25    }
26    let code = QrCode::new(text.as_bytes())
27        .map_err(|e| CliError::new(ErrorKind::Data, format!("qr encode: {e}")))?;
28    let fmt = format.trim().to_ascii_lowercase();
29    match fmt.as_str() {
30        "png" | "image" => {
31            let img = code.render::<Luma<u8>>().quiet_zone(true).build();
32            let out = resolve_out_path(path, "qr.png")?;
33            img.save(&out).map_err(|e| {
34                CliError::new(
35                    ErrorKind::Io,
36                    format!("write qr png {}: {e}", out.display()),
37                )
38            })?;
39            Ok(json!({
40                "action": "encode",
41                "format": "png",
42                "path": out.display().to_string(),
43                "bytes": text.len(),
44                "engine": "qrcode",
45            }))
46        }
47        "svg" => {
48            let svg = code
49                .render::<qrcode::render::svg::Color>()
50                .quiet_zone(true)
51                .build();
52            let out = resolve_out_path(path, "qr.svg")?;
53            fs::write(&out, svg.as_bytes()).map_err(|e| {
54                CliError::new(
55                    ErrorKind::Io,
56                    format!("write qr svg {}: {e}", out.display()),
57                )
58            })?;
59            Ok(json!({
60                "action": "encode",
61                "format": "svg",
62                "path": out.display().to_string(),
63                "engine": "qrcode",
64            }))
65        }
66        "terminal" | "text" | "unicode" => {
67            let s = code
68                .render::<char>()
69                .quiet_zone(true)
70                .module_dimensions(2, 1)
71                .build();
72            Ok(json!({
73                "action": "encode",
74                "format": "terminal",
75                "matrix": s,
76                "engine": "qrcode",
77            }))
78        }
79        other => Err(CliError::with_suggestion(
80            ErrorKind::Usage,
81            format!("unknown qr format: {other}"),
82            "Use png|svg|terminal",
83        )),
84    }
85}
86
87/// Decode QR payload from an image file.
88pub fn decode(path: &Path) -> Result<Value, CliError> {
89    let img = image::open(path).map_err(|e| {
90        CliError::new(
91            ErrorKind::Io,
92            format!("qr decode open {}: {e}", path.display()),
93        )
94    })?;
95    let luma = img.to_luma8();
96    let mut prepared = rqrr::PreparedImage::prepare(luma);
97    let grids = prepared.detect_grids();
98    if grids.is_empty() {
99        return Err(CliError::with_suggestion(
100            ErrorKind::Data,
101            "no QR code detected in image",
102            "Use a clear PNG/JPEG of a QR with quiet zone",
103        ));
104    }
105    let mut payloads = Vec::new();
106    for g in grids {
107        match g.decode() {
108            Ok((_meta, content)) => payloads.push(content),
109            Err(e) => payloads.push(format!("decode_error:{e}")),
110        }
111    }
112    Ok(json!({
113        "action": "decode",
114        "path": path.display().to_string(),
115        "count": payloads.len(),
116        "payloads": payloads,
117        "engine": "rqrr",
118    }))
119}
120
121fn resolve_out_path(path: Option<&Path>, default_name: &str) -> Result<PathBuf, CliError> {
122    if let Some(p) = path {
123        if let Some(parent) = p.parent() {
124            if !parent.as_os_str().is_empty() {
125                fs::create_dir_all(parent)
126                    .map_err(|e| CliError::new(ErrorKind::Io, format!("create qr dir: {e}")))?;
127            }
128        }
129        return Ok(p.to_path_buf());
130    }
131    let dir = xdg::cache_dir().unwrap_or_else(|_| PathBuf::from("."));
132    fs::create_dir_all(&dir).ok();
133    Ok(dir.join(default_name))
134}