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.
- qrcode_ai_scanner-0.3.0/PKG-INFO +49 -0
- qrcode_ai_scanner-0.3.0/README.md +29 -0
- qrcode_ai_scanner-0.3.0/pyproject.toml +32 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/Cargo.toml +57 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/README.md +25 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/benches/decode.rs +46 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/proptest-regressions/gs1.txt +7 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/proptest-regressions/rescue/ee.txt +7 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/engine/charset.rs +96 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/engine/mod.rs +278 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/engine/rqrr_engine.rs +88 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/engine/rxing_engine.rs +151 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/error.rs +182 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/gs1.rs +592 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/input.rs +309 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/ladder.rs +1134 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/lib.rs +228 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/payload.rs +754 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/report.rs +762 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/rescue/bitstream.rs +261 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/rescue/ee.rs +338 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/rescue/mod.rs +328 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/score/iso15415.rs +290 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/score/mod.rs +740 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/score/structural.rs +309 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/score/uec.rs +748 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/score/warp.rs +406 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/snapshots/qrcode_ai_scanner__report__tests__scan_report_empty.snap +19 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/snapshots/qrcode_ai_scanner__report__tests__scan_report_full.snap +132 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/src/transform.rs +534 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/tests/scan_integration.rs +748 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner/tests/spec_golden.rs +78 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner-py/Cargo.lock +1024 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner-py/Cargo.toml +26 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner-py/README.md +29 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner-py/py.typed +0 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner-py/python/tests/test_scan.py +105 -0
- qrcode_ai_scanner-0.3.0/qrcode-ai-scanner-py/qrcode_ai_scanner.pyi +36 -0
- 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
|
+
}
|