qrcode-ai-scanner 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. qrcode_ai_scanner-0.3.0/PKG-INFO +49 -0
  2. qrcode_ai_scanner-0.3.0/README.md +29 -0
  3. qrcode_ai_scanner-0.3.0/pyproject.toml +32 -0
  4. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/Cargo.toml +57 -0
  5. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/README.md +25 -0
  6. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/benches/decode.rs +46 -0
  7. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/proptest-regressions/gs1.txt +7 -0
  8. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/proptest-regressions/rescue/ee.txt +7 -0
  9. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/engine/charset.rs +96 -0
  10. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/engine/mod.rs +278 -0
  11. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/engine/rqrr_engine.rs +88 -0
  12. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/engine/rxing_engine.rs +151 -0
  13. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/error.rs +182 -0
  14. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/gs1.rs +592 -0
  15. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/input.rs +309 -0
  16. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/ladder.rs +1134 -0
  17. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/lib.rs +228 -0
  18. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/payload.rs +754 -0
  19. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/report.rs +762 -0
  20. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/rescue/bitstream.rs +261 -0
  21. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/rescue/ee.rs +338 -0
  22. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/rescue/mod.rs +328 -0
  23. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/score/iso15415.rs +290 -0
  24. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/score/mod.rs +740 -0
  25. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/score/structural.rs +309 -0
  26. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/score/uec.rs +748 -0
  27. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/score/warp.rs +406 -0
  28. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/snapshots/qrcode_ai_scanner__report__tests__scan_report_empty.snap +19 -0
  29. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/snapshots/qrcode_ai_scanner__report__tests__scan_report_full.snap +132 -0
  30. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/transform.rs +534 -0
  31. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/tests/scan_integration.rs +748 -0
  32. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/tests/spec_golden.rs +78 -0
  33. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner-py/Cargo.lock +1024 -0
  34. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner-py/Cargo.toml +26 -0
  35. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner-py/README.md +29 -0
  36. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner-py/py.typed +0 -0
  37. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner-py/python/tests/test_scan.py +105 -0
  38. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner-py/qrcode_ai_scanner.pyi +36 -0
  39. qrcode_ai_scanner-0.3.0/qrcode-ai-scanner-py/src/lib.rs +79 -0
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: qrcode-ai-scanner
3
+ Version: 0.3.0
4
+ Classifier: Programming Language :: Rust
5
+ Classifier: Programming Language :: Python :: 3 :: Only
6
+ Classifier: Topic :: Multimedia :: Graphics
7
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
8
+ Requires-Dist: pytest ; extra == 'test'
9
+ Requires-Dist: jsonschema ; extra == 'test'
10
+ Requires-Dist: pillow ; extra == 'test'
11
+ Provides-Extra: test
12
+ Summary: QR decoding + scannability scoring for artistic, AI-generated, and photo-captured QR codes — Rust core via PyO3.
13
+ Keywords: qrcode,qr,scanner,decoder,barcode,validation
14
+ License-Expression: AGPL-3.0-or-later
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
17
+ Project-URL: Documentation, https://github.com/supernovae-st/qrcode-ai-scanner/tree/main/docs
18
+ Project-URL: Repository, https://github.com/supernovae-st/qrcode-ai-scanner
19
+
20
+ # qrcode-ai-scanner (Python)
21
+
22
+ QR decoding + scannability scoring for **artistic / AI-generated / photo-captured** QR
23
+ codes — the Rust [`qrcode-ai-scanner`](https://crates.io/crates/qrcode-ai-scanner) engine,
24
+ via PyO3. Same versioned `ScanReport` contract as the Rust, Node and WASM surfaces.
25
+
26
+ ```python
27
+ import qrcode_ai_scanner as qr
28
+
29
+ with open("image.png", "rb") as f:
30
+ report = qr.scan(f.read(), profile="full") # "full" | "fast" | "frame"
31
+
32
+ if report["detections"]:
33
+ d = report["detections"][0]
34
+ print(d["content"]["text"])
35
+ print(report["score"]["value"], report["score"]["grade"]) # 0-100 + ISO band
36
+ print(report["hints"]) # machine-actionable
37
+ ```
38
+
39
+ - `scan(image: bytes, profile="full") -> dict` — decode encoded bytes (PNG/JPEG/WebP/GIF).
40
+ - `scan_frame(rgba: bytes, width: int, height: int, profile="frame") -> dict` — raw RGBA
41
+ frame, no image-format roundtrip.
42
+ - "No QR found" returns a `dict` with empty `detections`; `ValueError` is raised only for
43
+ invalid input.
44
+
45
+ The returned `dict` is the same `ScanReport` documented in the
46
+ [spec](https://github.com/supernovae-st/qrcode-ai-scanner/tree/main/spec).
47
+
48
+ License: AGPL-3.0-or-later · © SuperNovae Studio.
49
+
@@ -0,0 +1,29 @@
1
+ # qrcode-ai-scanner (Python)
2
+
3
+ QR decoding + scannability scoring for **artistic / AI-generated / photo-captured** QR
4
+ codes — the Rust [`qrcode-ai-scanner`](https://crates.io/crates/qrcode-ai-scanner) engine,
5
+ via PyO3. Same versioned `ScanReport` contract as the Rust, Node and WASM surfaces.
6
+
7
+ ```python
8
+ import qrcode_ai_scanner as qr
9
+
10
+ with open("image.png", "rb") as f:
11
+ report = qr.scan(f.read(), profile="full") # "full" | "fast" | "frame"
12
+
13
+ if report["detections"]:
14
+ d = report["detections"][0]
15
+ print(d["content"]["text"])
16
+ print(report["score"]["value"], report["score"]["grade"]) # 0-100 + ISO band
17
+ print(report["hints"]) # machine-actionable
18
+ ```
19
+
20
+ - `scan(image: bytes, profile="full") -> dict` — decode encoded bytes (PNG/JPEG/WebP/GIF).
21
+ - `scan_frame(rgba: bytes, width: int, height: int, profile="frame") -> dict` — raw RGBA
22
+ frame, no image-format roundtrip.
23
+ - "No QR found" returns a `dict` with empty `detections`; `ValueError` is raised only for
24
+ invalid input.
25
+
26
+ The returned `dict` is the same `ScanReport` documented in the
27
+ [spec](https://github.com/supernovae-st/qrcode-ai-scanner/tree/main/spec).
28
+
29
+ License: AGPL-3.0-or-later · © SuperNovae Studio.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["maturin>=1.7,<2.0"]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "qrcode-ai-scanner"
7
+ description = "QR decoding + scannability scoring for artistic, AI-generated, and photo-captured QR codes — Rust core via PyO3."
8
+ requires-python = ">=3.8"
9
+ readme = "README.md"
10
+ license = "AGPL-3.0-or-later"
11
+ keywords = ["qrcode", "qr", "scanner", "decoder", "barcode", "validation"]
12
+ classifiers = [
13
+ "Programming Language :: Rust",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Topic :: Multimedia :: Graphics",
16
+ "Topic :: Scientific/Engineering :: Image Recognition",
17
+ ]
18
+ dynamic = ["version"]
19
+
20
+ [project.optional-dependencies]
21
+ test = ["pytest", "jsonschema", "pillow"]
22
+
23
+ [project.urls]
24
+ Repository = "https://github.com/supernovae-st/qrcode-ai-scanner"
25
+ Documentation = "https://github.com/supernovae-st/qrcode-ai-scanner/tree/main/docs"
26
+
27
+ [tool.maturin]
28
+ module-name = "qrcode_ai_scanner"
29
+ manifest-path = "qrcode-ai-scanner-py/Cargo.toml"
30
+
31
+ [tool.maturin.generate-ci.github]
32
+ trusted-publishing = true
@@ -0,0 +1,57 @@
1
+ [package]
2
+ name = "qrcode-ai-scanner"
3
+ description = "QR code decoding and scannability scoring for artistic, AI-generated, and photo-captured QR codes"
4
+ keywords = ["qrcode", "qr", "scanner", "validation", "decoder"]
5
+ categories = ["multimedia::images", "encoding", "computer-vision"]
6
+ readme = "README.md"
7
+ version = "0.3.0"
8
+ edition = "2024"
9
+ rust-version = "1.87.0"
10
+ license = "AGPL-3.0-or-later"
11
+ repository = "https://github.com/supernovae-st/qrcode-ai-scanner"
12
+ authors = ["Thibaut MÉLEN <thibaut@supernovae.studio>", "SuperNovae <contact@supernovae.studio>"]
13
+
14
+ [features]
15
+ default = ["engine-rxing", "engine-rqrr", "parallel", "serde"]
16
+ # decode engines — at least one is required (compile_error! otherwise)
17
+ engine-rxing = ["dep:rxing"]
18
+ engine-rqrr = ["dep:rqrr"]
19
+ # rayon parallelism — native targets only, keep OFF for wasm
20
+ parallel = ["dep:rayon"]
21
+ # ScanReport (de)serialization — the cross-boundary contract
22
+ serde = ["dep:serde", "dep:base64"]
23
+
24
+ [dependencies]
25
+ rxing = { version = "^0.9.1", default-features = false, optional = true, features = ["decoders", "multi_barcode_readers", "full_barcode_format_support", "encoding_rs"] }
26
+ rqrr = { version = "^0.10.1", optional = true }
27
+ image = { version = "^0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
28
+ encoding_rs = "^0.8"
29
+ serde = { version = "^1", optional = true, features = ["derive"] }
30
+ base64 = { version = "^0.22", optional = true }
31
+ thiserror = "^2"
32
+ miette = "^7"
33
+ web-time = "^1.1"
34
+ rayon = { version = "^1.10", optional = true }
35
+
36
+ [dev-dependencies]
37
+ jsonschema = { version = "0.33", default-features = false }
38
+ criterion = { version = "^0.8", features = ["html_reports"] }
39
+ qrcode = "^0.14"
40
+ insta = { version = "^1", features = ["json", "redactions"] }
41
+ proptest = "^1"
42
+ pretty_assertions = "^1"
43
+ serde_json = "^1"
44
+
45
+ [lints.rust]
46
+ missing_docs = "warn"
47
+ # cargo-fuzz builds the graph with --cfg fuzzing (fuzz-only pub surface)
48
+ unexpected_cfgs = { level = "warn", check-cfg = ["cfg(fuzzing)"] }
49
+
50
+ [lints.clippy]
51
+ unwrap_used = "deny"
52
+ expect_used = "deny"
53
+ pedantic = { level = "warn", priority = -1 }
54
+
55
+ [[bench]]
56
+ name = "decode"
57
+ harness = false
@@ -0,0 +1,25 @@
1
+ # qrcode-ai-scanner
2
+
3
+ QR decoding + scannability scoring for **artistic, AI-generated, and
4
+ photo-captured** QR codes. Deterministic multi-engine ladder (rxing + rqrr),
5
+ score contract v3 (six survival-ramp stress axes incl. perspective/rotation/
6
+ lighting, finder-integrity + quiet-zone caps), and the **synthetic UEC** —
7
+ the ISO 15415 unused-error-correction margin computed from the engine's own
8
+ sampled bitstream. Machine-actionable hints close the generate → scan →
9
+ regenerate loop.
10
+
11
+ ```rust
12
+ use qrcode_ai_scanner::{ImageInput, ScanProfile, Scanner};
13
+
14
+ let scanner = Scanner::builder().profile(ScanProfile::Full).build();
15
+ let report = scanner.scan(ImageInput::encoded(&bytes))?;
16
+ // report.detections · report.score (value/grade/axes/uec) · report.hints
17
+ ```
18
+
19
+ "No QR found" is `Ok` with empty detections — `Err` is reserved for invalid
20
+ input and cancellation (`QRS-001..005`). Camera frames skip the PNG roundtrip
21
+ via `ImageInput::rgba8`. Full docs: the
22
+ [repository](https://github.com/supernovae-st/qrcode-ai-scanner) —
23
+ `docs/ARCHITECTURE.md` + `docs/SCORING.md`.
24
+
25
+ License: AGPL-3.0-or-later · © SuperNovae Studio.
@@ -0,0 +1,46 @@
1
+ //! Decode + score benchmarks over the committed corpus images.
2
+ //! Local wall-clock reference (criterion); CI regression gating runs the
3
+ //! corpus-report timings on fixed runners instead — see ci.yml.
4
+
5
+ #![allow(clippy::unwrap_used, clippy::expect_used, missing_docs)]
6
+
7
+ use criterion::{Criterion, criterion_group, criterion_main};
8
+ use qrcode_ai_scanner::{ImageInput, ScanProfile, Scanner};
9
+ use std::hint::black_box;
10
+
11
+ fn fixture(rel: &str) -> Vec<u8> {
12
+ std::fs::read(format!(
13
+ "{}/../../fixtures/{rel}",
14
+ env!("CARGO_MANIFEST_DIR")
15
+ ))
16
+ .unwrap()
17
+ }
18
+
19
+ fn bench_decode(c: &mut Criterion) {
20
+ let clean = fixture("clean/gen_v5_q.png");
21
+ let artistic = fixture("artistic/OK_1069ms_85_8b6a54b3.png");
22
+
23
+ let frame = Scanner::builder().profile(ScanProfile::Frame).build();
24
+ let fast = Scanner::builder().profile(ScanProfile::Fast).build();
25
+ let full = Scanner::builder().profile(ScanProfile::Full).build();
26
+
27
+ c.bench_function("frame_clean", |b| {
28
+ b.iter(|| frame.scan(black_box(ImageInput::encoded(&clean))).unwrap());
29
+ });
30
+ c.bench_function("fast_clean_scored", |b| {
31
+ b.iter(|| fast.scan(black_box(ImageInput::encoded(&clean))).unwrap());
32
+ });
33
+ c.bench_function("full_artistic", |b| {
34
+ b.iter(|| {
35
+ full.scan(black_box(ImageInput::encoded(&artistic)))
36
+ .unwrap()
37
+ });
38
+ });
39
+ }
40
+
41
+ criterion_group! {
42
+ name = benches;
43
+ config = Criterion::default().sample_size(10);
44
+ targets = bench_decode
45
+ }
46
+ criterion_main!(benches);
@@ -0,0 +1,7 @@
1
+ # Seeds for failure cases proptest has generated in the past. It is
2
+ # automatically read and these particular cases re-run before any
3
+ # novel cases are generated.
4
+ #
5
+ # It is recommended to check this file in to source control so that
6
+ # everyone who runs the test benefits from these saved cases.
7
+ cc 409e7ccab8e1538e12bb47ec375084a6fd12bb59ee5a2aec26beb0837dd92157 # shrinks to s = "ࠀ"
@@ -0,0 +1,7 @@
1
+ # Seeds for failure cases proptest has generated in the past. It is
2
+ # automatically read and these particular cases re-run before any
3
+ # novel cases are generated.
4
+ #
5
+ # It is recommended to check this file in to source control so that
6
+ # everyone who runs the test benefits from these saved cases.
7
+ cc 5ad5a32402feb81f1d65b31c3cba983189e8c3a56aa42f559b9c4f7e676e48c4 # shrinks to seed = 1, npar = 4
@@ -0,0 +1,96 @@
1
+ //! Charset resolution for raw decoded bytes — done ONCE here, never per-engine.
2
+ //!
3
+ //! Order: strict UTF-8 → isolated-byte Latin-1 short-circuit → Shift-JIS
4
+ //! (error-free decode) → windows-1252 (never fails).
5
+ //!
6
+ //! The short-circuit is the load-bearing part: most Latin-1 accented bytes
7
+ //! (0x81-0x9F · 0xE0-0xFC) followed by an ASCII letter form a VALID
8
+ //! Shift-JIS kanji pair, so "SJIS decodes error-free" alone garbles the
9
+ //! dominant legacy European class ("Québec" → "Qu饕ec", review-proven).
10
+ //! Real Shift-JIS text has RUNS of non-ASCII bytes (kanji pairs, katakana
11
+ //! sequences); Latin-1 European text has accented bytes ISOLATED between
12
+ //! ASCII. Known residual ambiguity: a single isolated half-width katakana
13
+ //! between ASCII letters now reads as Latin-1 — inherent, documented.
14
+
15
+ use crate::report::Charset;
16
+
17
+ /// Every non-ASCII byte sits alone between ASCII neighbours (or string
18
+ /// edges) — the Latin-1-not-SJIS signature.
19
+ fn all_non_ascii_isolated(raw: &[u8]) -> bool {
20
+ raw.iter().enumerate().all(|(i, b)| {
21
+ b.is_ascii()
22
+ || ((i == 0 || raw[i - 1].is_ascii()) && (i + 1 == raw.len() || raw[i + 1].is_ascii()))
23
+ })
24
+ }
25
+
26
+ /// Resolve raw payload bytes into text + the charset that produced it.
27
+ pub(crate) fn resolve(raw: &[u8]) -> (String, Charset) {
28
+ if let Ok(s) = std::str::from_utf8(raw) {
29
+ return (s.to_owned(), Charset::Utf8);
30
+ }
31
+ if all_non_ascii_isolated(raw) {
32
+ let (text, _, _) = encoding_rs::WINDOWS_1252.decode(raw);
33
+ return (text.into_owned(), Charset::Latin1);
34
+ }
35
+ let (text, _, had_errors) = encoding_rs::SHIFT_JIS.decode(raw);
36
+ if !had_errors {
37
+ return (text.into_owned(), Charset::ShiftJis);
38
+ }
39
+ let (text, _, _) = encoding_rs::WINDOWS_1252.decode(raw);
40
+ (text.into_owned(), Charset::Latin1)
41
+ }
42
+
43
+ #[cfg(test)]
44
+ mod tests {
45
+ #![allow(clippy::unwrap_used, clippy::expect_used)]
46
+
47
+ use super::*;
48
+
49
+ fn windows_1252(s: &str) -> Vec<u8> {
50
+ let (bytes, _, had_errors) = encoding_rs::WINDOWS_1252.encode(s);
51
+ assert!(!had_errors);
52
+ bytes.into_owned()
53
+ }
54
+
55
+ #[test]
56
+ fn latin1_accented_words_resolve_as_latin1_not_garbled_sjis() {
57
+ // review-proven garble class: accented byte + ASCII letter forms a
58
+ // valid SJIS kanji pair ("Québec" read "Qu饕ec" pre-fix)
59
+ for truth in ["Québec", "señor", "crème brûlée", "café au lait", "Éé"] {
60
+ let raw = windows_1252(truth);
61
+ let (text, charset) = resolve(&raw);
62
+ assert_eq!(text, truth);
63
+ assert_eq!(charset, Charset::Latin1, "{truth}");
64
+ }
65
+ }
66
+
67
+ #[test]
68
+ fn real_shift_jis_runs_still_resolve_as_sjis() {
69
+ // kanji pairs = consecutive non-ASCII bytes — not isolated
70
+ let (raw, _, had_errors) = encoding_rs::SHIFT_JIS.encode("日本語テスト");
71
+ assert!(!had_errors);
72
+ let (text, charset) = resolve(&raw);
73
+ assert_eq!(text, "日本語テスト");
74
+ assert_eq!(charset, Charset::ShiftJis);
75
+ // mixed ASCII + kanji run keeps the SJIS reading too
76
+ let (raw, _, _) = encoding_rs::SHIFT_JIS.encode("QR: 漢字");
77
+ let (text, charset) = resolve(&raw);
78
+ assert_eq!(text, "QR: 漢字");
79
+ assert_eq!(charset, Charset::ShiftJis);
80
+ }
81
+
82
+ #[test]
83
+ fn utf8_always_wins() {
84
+ let (text, charset) = resolve("déjà 日本".as_bytes());
85
+ assert_eq!(text, "déjà 日本");
86
+ assert_eq!(charset, Charset::Utf8);
87
+ }
88
+
89
+ #[test]
90
+ fn invalid_everywhere_falls_back_to_latin1_lossy() {
91
+ // 0xFF is outside the SJIS lead ranges → SJIS errors; consecutive
92
+ // non-ASCII → not isolated → the final lossy Latin-1 fallback
93
+ let (_, charset) = resolve(&[0xFF, 0xFF]);
94
+ assert_eq!(charset, Charset::Latin1);
95
+ }
96
+ }
@@ -0,0 +1,278 @@
1
+ //! Decode engines — isolated wrappers around third-party decoders.
2
+ //!
3
+ //! Contract: engines NEVER panic outward (`catch_unwind` boundary), never
4
+ //! decide charset (raw bytes are the truth, resolution happens once here in
5
+ //! `charset`), and only report what they actually measured — no stubs.
6
+
7
+ pub(crate) mod charset;
8
+ #[cfg(feature = "engine-rqrr")]
9
+ mod rqrr_engine;
10
+ #[cfg(feature = "engine-rxing")]
11
+ mod rxing_engine;
12
+
13
+ use std::panic::{AssertUnwindSafe, catch_unwind};
14
+
15
+ use crate::input::LumaImage;
16
+ use crate::report::{EcLevel, EngineKind, Point, Symbology};
17
+
18
+ /// Still-masked codeword bitstream as the engine sampled it (rqrr only) —
19
+ /// feeds the synthetic UEC.
20
+ #[derive(Debug, Clone)]
21
+ pub(crate) struct MaskedStream {
22
+ /// Packed bits, MSB-first per byte.
23
+ pub bits: Vec<u8>,
24
+ /// Stream length in bits.
25
+ pub bit_len: usize,
26
+ }
27
+
28
+ /// What an engine actually measured — no stubs, `None` means "not provided".
29
+ #[derive(Debug, Clone)]
30
+ pub(crate) struct RawDetection {
31
+ /// Symbology the engine read this as.
32
+ pub symbology: Symbology,
33
+ /// Decoded payload bytes (charset-independent truth).
34
+ pub raw: Vec<u8>,
35
+ /// Raw sampled bitstream when the engine exposes it (rqrr).
36
+ pub masked_stream: Option<MaskedStream>,
37
+ /// Symbol corners when the engine provides true bounds (rqrr).
38
+ pub corners: Option<[Point; 4]>,
39
+ /// Symbol version 1-40 when measured.
40
+ pub version: Option<u8>,
41
+ /// Error-correction level when measured.
42
+ pub ec: Option<EcLevel>,
43
+ /// Mask pattern 0-7 when measured.
44
+ pub mask: Option<u8>,
45
+ /// FNC1-in-first-position symbol (GS1 formatted data — symbology
46
+ /// `]Q3`/`]Q4`). Only rxing surfaces this; rqrr cannot decode FNC1
47
+ /// symbols at all (its mode dispatch rejects indicators 0x5/0x9).
48
+ pub fnc1: bool,
49
+ /// Producing engine.
50
+ pub engine: EngineKind,
51
+ }
52
+
53
+ /// Aggregated result of one pass over all enabled engines.
54
+ #[derive(Debug, Clone, Default)]
55
+ pub(crate) struct EngineOutcome {
56
+ /// Every detection from every engine (deduplication is the ladder's job).
57
+ pub detections: Vec<RawDetection>,
58
+ /// Grids detected but NOT decoded (rqrr `get_raw_data` ok, RS failed) —
59
+ /// the S5 rescue inputs. Corners are in ATTEMPT space here.
60
+ pub rescue: Vec<crate::rescue::RescueCandidate>,
61
+ /// Engine panics caught and isolated during this pass. NOTE: isolation
62
+ /// stops the unwind, but the process-global panic HOOK still runs first
63
+ /// — server embedders see a backtrace on stderr per caught panic
64
+ /// (suppress via `std::panic::set_hook` in the application if needed;
65
+ /// a library must not touch the global hook).
66
+ pub panics: u8,
67
+ }
68
+
69
+ /// Run a closure with a panic boundary. `None` = the closure panicked.
70
+ fn run_isolated<T>(f: impl FnOnce() -> T) -> Option<T> {
71
+ catch_unwind(AssertUnwindSafe(f)).ok()
72
+ }
73
+
74
+ /// Which symbologies a decode pass tries — the cost lever: every extra
75
+ /// format family is another detector walking the image.
76
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
77
+ pub(crate) enum FormatFilter {
78
+ /// Everything rxing supports + rqrr.
79
+ All,
80
+ /// QR · Micro QR · rMQR (+ rqrr) — the artistic-recovery stages are
81
+ /// QR-calibrated; paying the 1D/PDF417/DataMatrix detectors on every
82
+ /// deep rung starves the budget for nothing.
83
+ QrFamily,
84
+ /// Exactly one symbology — the scoring fast path (30 stress cells ×
85
+ /// every decoder is 1× vs ~5× scan cost, and a cross-symbology text
86
+ /// match would be a false survival signal anyway).
87
+ Only(Symbology),
88
+ }
89
+
90
+ /// One decode pass: every enabled engine, all symbologies.
91
+ #[cfg(test)]
92
+ pub(crate) fn decode_all(luma: &LumaImage) -> EngineOutcome {
93
+ decode_filtered(luma, FormatFilter::All)
94
+ }
95
+
96
+ /// Decode under a format filter.
97
+ pub(crate) fn decode_filtered(luma: &LumaImage, filter: FormatFilter) -> EngineOutcome {
98
+ let mut outcome = EngineOutcome::default();
99
+
100
+ #[cfg(feature = "engine-rxing")]
101
+ match run_isolated(|| rxing_engine::decode(luma, filter)) {
102
+ Some(found) => outcome.detections.extend(found),
103
+ None => outcome.panics += 1,
104
+ }
105
+
106
+ #[cfg(feature = "engine-rqrr")]
107
+ if matches!(
108
+ filter,
109
+ FormatFilter::All | FormatFilter::QrFamily | FormatFilter::Only(Symbology::QrCode)
110
+ ) {
111
+ match run_isolated(|| rqrr_engine::decode(luma)) {
112
+ Some((found, rescue)) => {
113
+ outcome.detections.extend(found);
114
+ outcome.rescue.extend(rescue);
115
+ }
116
+ None => outcome.panics += 1,
117
+ }
118
+ }
119
+
120
+ outcome
121
+ }
122
+
123
+ #[cfg(test)]
124
+ mod tests {
125
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::cast_precision_loss)]
126
+
127
+ use super::*;
128
+ use crate::input::LumaImage;
129
+ use crate::report::{Charset, EcLevel, EngineKind};
130
+
131
+ /// Render a QR to luma bytes module-by-module (no image-crate coupling):
132
+ /// `scale` px per module + 4-module quiet zone. Returns the symbol version.
133
+ fn qr_luma(content: &[u8], ec: qrcode::EcLevel, scale: u32) -> (LumaImage, u8) {
134
+ let code = qrcode::QrCode::with_error_correction_level(content, ec).unwrap();
135
+ let modules = code.width();
136
+ let colors = code.to_colors();
137
+ let qz = 4u32;
138
+ let side = (u32::try_from(modules).unwrap() + 2 * qz) * scale;
139
+ let mut data = vec![255u8; (side * side) as usize];
140
+ for my in 0..modules {
141
+ for mx in 0..modules {
142
+ if colors[my * modules + mx] == qrcode::Color::Dark {
143
+ for dy in 0..scale {
144
+ for dx in 0..scale {
145
+ let x = (u32::try_from(mx).unwrap() + qz) * scale + dx;
146
+ let y = (u32::try_from(my).unwrap() + qz) * scale + dy;
147
+ data[(y * side + x) as usize] = 0;
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+ let version = match code.version() {
154
+ qrcode::Version::Normal(v) => u8::try_from(v).unwrap(),
155
+ qrcode::Version::Micro(_) => 0,
156
+ };
157
+ (LumaImage::new(data, side, side), version)
158
+ }
159
+
160
+ #[cfg(feature = "engine-rxing")]
161
+ #[test]
162
+ fn rxing_decodes_utf8_qr_with_text_and_ec() {
163
+ let payload = "héllo → 🦋";
164
+ let (luma, _) = qr_luma(payload.as_bytes(), qrcode::EcLevel::Q, 4);
165
+ let outcome = decode_all(&luma);
166
+ let d = outcome
167
+ .detections
168
+ .iter()
169
+ .find(|d| d.engine == EngineKind::Rxing)
170
+ .expect("rxing must decode a clean generated QR");
171
+ let (text, _) = charset::resolve(&d.raw);
172
+ assert_eq!(text, payload);
173
+ assert_eq!(d.raw, payload.as_bytes());
174
+ assert_eq!(d.ec, Some(EcLevel::Q));
175
+ assert_eq!(outcome.panics, 0);
176
+ }
177
+
178
+ #[cfg(feature = "engine-rqrr")]
179
+ #[test]
180
+ fn rqrr_provides_geometry_version_ec_mask() {
181
+ let (luma, version) = qr_luma(b"https://qrcode-ai.com/scan", qrcode::EcLevel::Q, 4);
182
+ let outcome = decode_all(&luma);
183
+ let d = outcome
184
+ .detections
185
+ .iter()
186
+ .find(|d| d.engine == EngineKind::Rqrr)
187
+ .expect("rqrr must decode a clean generated QR");
188
+ assert_eq!(d.version, Some(version));
189
+ assert_eq!(d.ec, Some(EcLevel::Q), "rqrr ecc_level 0-3 mapping");
190
+ let mask = d.mask.expect("rqrr reports the mask");
191
+ assert!(mask <= 7, "mask {mask}");
192
+ let corners = d.corners.expect("rqrr reports bounds");
193
+ let side = luma.width() as f32;
194
+ for p in corners {
195
+ assert!(
196
+ p.x >= 0.0 && p.x < side && p.y >= 0.0 && p.y < side,
197
+ "{p:?}"
198
+ );
199
+ }
200
+ }
201
+
202
+ #[cfg(feature = "engine-rqrr")]
203
+ #[test]
204
+ fn rqrr_ec_mapping_pinned_on_l_too() {
205
+ let (luma, _) = qr_luma(b"ec level L pin", qrcode::EcLevel::L, 4);
206
+ let outcome = decode_all(&luma);
207
+ let d = outcome
208
+ .detections
209
+ .iter()
210
+ .find(|d| d.engine == EngineKind::Rqrr)
211
+ .expect("decode");
212
+ assert_eq!(d.ec, Some(EcLevel::L));
213
+ }
214
+
215
+ #[cfg(all(feature = "engine-rxing", feature = "engine-rqrr"))]
216
+ #[test]
217
+ fn engines_agree_on_raw_payload_bytes() {
218
+ let payload = "parity check 1234";
219
+ let (luma, _) = qr_luma(payload.as_bytes(), qrcode::EcLevel::M, 4);
220
+ let outcome = decode_all(&luma);
221
+ let rxing_raw = outcome
222
+ .detections
223
+ .iter()
224
+ .find(|d| d.engine == EngineKind::Rxing)
225
+ .map(|d| d.raw.clone())
226
+ .expect("rxing");
227
+ let rqrr_raw = outcome
228
+ .detections
229
+ .iter()
230
+ .find(|d| d.engine == EngineKind::Rqrr)
231
+ .map(|d| d.raw.clone())
232
+ .expect("rqrr");
233
+ assert_eq!(rxing_raw, rqrr_raw);
234
+ assert_eq!(rxing_raw, payload.as_bytes());
235
+ }
236
+
237
+ #[cfg(feature = "engine-rqrr")]
238
+ #[test]
239
+ fn latin1_byte_mode_payload_resolves_via_raw_path() {
240
+ // "Éé" in windows-1252 — invalid UTF-8, the rqrr decode() trap case.
241
+ let (luma, _) = qr_luma(&[0xC9, 0xE9], qrcode::EcLevel::M, 4);
242
+ let outcome = decode_all(&luma);
243
+ let d = outcome
244
+ .detections
245
+ .iter()
246
+ .find(|d| d.engine == EngineKind::Rqrr)
247
+ .expect("rqrr must decode byte-mode latin1");
248
+ assert_eq!(d.raw, vec![0xC9, 0xE9]);
249
+ let (text, cs) = charset::resolve(&d.raw);
250
+ assert_eq!(text, "Éé");
251
+ assert_eq!(cs, Charset::Latin1);
252
+ }
253
+
254
+ #[test]
255
+ fn charset_resolution_order_utf8_sjis_latin1() {
256
+ let (t, c) = charset::resolve("plain ascii".as_bytes());
257
+ assert_eq!((t.as_str(), c), ("plain ascii", Charset::Utf8));
258
+
259
+ let (t, c) = charset::resolve("héllo🦋".as_bytes());
260
+ assert_eq!((t.as_str(), c), ("héllo🦋", Charset::Utf8));
261
+
262
+ // こんにちは in Shift-JIS
263
+ let sjis: &[u8] = &[0x82, 0xB1, 0x82, 0xF1, 0x82, 0xC9, 0x82, 0xBF, 0x82, 0xCD];
264
+ let (t, c) = charset::resolve(sjis);
265
+ assert_eq!((t.as_str(), c), ("こんにちは", Charset::ShiftJis));
266
+
267
+ let (t, c) = charset::resolve(&[0xC9, 0xE9]);
268
+ assert_eq!((t.as_str(), c), ("Éé", Charset::Latin1));
269
+ }
270
+
271
+ #[test]
272
+ fn panics_are_isolated_not_propagated() {
273
+ let caught: Option<u8> = run_isolated(|| panic!("engine exploded"));
274
+ assert!(caught.is_none());
275
+ let fine = run_isolated(|| 7u8);
276
+ assert_eq!(fine, Some(7));
277
+ }
278
+ }