diff --git a/Cargo.lock b/Cargo.lock index 1055f09..4c7d21d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -767,23 +767,23 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rxing-cli" -version = "0.0.9" +version = "0.0.10" dependencies = [ "anyhow", "base64", "clap", "image", "rxing-reader", - "serde", "serde_json", "ureq", ] [[package]] name = "rxing-reader" -version = "0.0.9" +version = "0.0.10" dependencies = [ "anyhow", + "base64", "bitvec", "chrono", "codepage-437", @@ -792,15 +792,17 @@ dependencies = [ "multimap", "num", "once_cell", + "serde", "thiserror", ] [[package]] name = "rxing-wasm" -version = "0.0.9" +version = "0.0.10" dependencies = [ "js-sys", "rxing-reader", + "serde-wasm-bindgen", "wasm-bindgen", ] @@ -820,6 +822,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" diff --git a/Cargo.toml b/Cargo.toml index 757359e..ad5ff68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "3" [workspace.package] -version = "0.0.9" +version = "0.0.10" [profile.release] codegen-units = 1 diff --git a/rxing-cli/Cargo.toml b/rxing-cli/Cargo.toml index 4f422fd..0dc3bff 100644 --- a/rxing-cli/Cargo.toml +++ b/rxing-cli/Cargo.toml @@ -10,11 +10,10 @@ name = "rxing-cli" path = "src/main.rs" [dependencies] -rxing-reader = { path = "../rxing-reader" } +rxing-reader = { path = "../rxing-reader", features = ["serde"] } anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } ureq = { version = "2.10", features = ["tls"] } base64 = "0.22" -serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/rxing-cli/src/main.rs b/rxing-cli/src/main.rs index 22683db..ee2611a 100644 --- a/rxing-cli/src/main.rs +++ b/rxing-cli/src/main.rs @@ -7,8 +7,8 @@ use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; use clap::{Parser, ValueEnum}; use image::ImageReader; -use rxing_reader::{decode_qr_codes_luma, rgba_to_luma}; -use serde::Serialize; +use rxing_reader::json_view::{SymbolView, symbol_to_view}; +use rxing_reader::{QrSymbol, decode_qr_codes_luma, rgba_to_luma}; const MAX_HTTP_BODY: u64 = 64 * 1024 * 1024; @@ -33,13 +33,6 @@ enum Format { Json, } -#[derive(Serialize)] -#[serde(untagged)] -enum Entry { - Text { text: String }, - BytesB64 { bytes_b64: String }, -} - fn main() -> ExitCode { match run() { Ok(code) => code, @@ -59,8 +52,8 @@ fn run() -> Result { Format::Text => 1, Format::Json => 0, }; - let results = decode_payloads(&rgba, w, h, max)?; - render(&results, cli.format) + let symbols = decode_symbols(&rgba, w, h, max)?; + render(symbols, cli.format) } fn load_bytes(source: &str) -> Result> { @@ -107,7 +100,7 @@ fn decode_image_bytes(bytes: &[u8]) -> Result<(Vec, usize, usize)> { Ok((rgba.into_raw(), w, h)) } -fn decode_payloads(rgba: &[u8], w: usize, h: usize, max: usize) -> Result>> { +fn decode_symbols(rgba: &[u8], w: usize, h: usize, max: usize) -> Result> { let luma = rgba_to_luma(rgba, w, h).map_err(anyhow::Error::msg)?; let primary = decode_qr_codes_luma(&luma, w, h, true, true, true, max)?; if !primary.is_empty() { @@ -117,28 +110,20 @@ fn decode_payloads(rgba: &[u8], w: usize, h: usize, max: usize) -> Result], format: Format) -> Result { +fn render(symbols: Vec, format: Format) -> Result { match format { - Format::Text => match results.first() { + Format::Text => match symbols.into_iter().next() { None => Ok(ExitCode::from(1)), - Some(bytes) => { - match std::str::from_utf8(bytes) { + Some(symbol) => { + match std::str::from_utf8(&symbol.bytes) { Ok(s) => println!("{s}"), - Err(_) => println!("base64:{}", BASE64.encode(bytes)), + Err(_) => println!("base64:{}", BASE64.encode(&symbol.bytes)), } Ok(ExitCode::SUCCESS) } }, Format::Json => { - let entries: Vec = results - .iter() - .map(|bytes| match std::str::from_utf8(bytes) { - Ok(s) => Entry::Text { text: s.to_string() }, - Err(_) => Entry::BytesB64 { - bytes_b64: BASE64.encode(bytes), - }, - }) - .collect(); + let entries: Vec = symbols.into_iter().map(symbol_to_view).collect(); let mut stdout = io::stdout().lock(); serde_json::to_writer(&mut stdout, &entries).context("writing JSON to stdout")?; stdout.write_all(b"\n").context("writing trailing newline")?; diff --git a/rxing-reader/Cargo.toml b/rxing-reader/Cargo.toml index 3fb3b5c..a55c007 100644 --- a/rxing-reader/Cargo.toml +++ b/rxing-reader/Cargo.toml @@ -5,6 +5,10 @@ description = "Trimmed QR reading-only fork of rxing v0.9.0 for rxing-wasm." license = "Apache-2.0" edition = "2024" +[features] +default = [] +serde = ["dep:serde", "dep:base64"] + [dependencies] anyhow = "1.0" bitvec = { version = "1", default-features = false, features = ["alloc"] } @@ -15,6 +19,8 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "wasm multimap = "0.10" num = "0.4" thiserror = "2.0" +serde = { version = "1", features = ["derive"], optional = true } +base64 = { version = "0.22", optional = true } [dev-dependencies] image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } diff --git a/rxing-reader/src/common/detect/decoder_result.rs b/rxing-reader/src/common/detect/decoder_result.rs index c9b7395..a2986ad 100644 --- a/rxing-reader/src/common/detect/decoder_result.rs +++ b/rxing-reader/src/common/detect/decoder_result.rs @@ -1,11 +1,24 @@ use std::sync::Arc; use crate::common::ECIStringBuilder; +use crate::qrcode::{ErrorCorrectionLevel, Mode}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StructuredAppendInfo { + pub index: u8, + pub count: u8, + pub parity: u8, +} #[derive(Debug, Default)] pub struct DecoderResult { content: ECIStringBuilder, error: Option>, + version: u32, + error_correction_level: Option, + mask: u8, + modes: Vec, + structured_append: Option, } impl DecoderResult { @@ -13,6 +26,11 @@ impl DecoderResult { DecoderResult { content: src, error: None, + version: 0, + error_correction_level: None, + mask: 0, + modes: Vec::new(), + structured_append: None, } } @@ -20,12 +38,52 @@ impl DecoderResult { self.content.symbology.code != 0 && self.error.is_none() } - pub fn into_bytes(self) -> Vec { - self.content.into_bytes() - } - pub fn with_error(mut self, error: Option) -> DecoderResult { self.error = error.map(Arc::new); self } + + pub fn with_format( + mut self, + version: u32, + error_correction_level: ErrorCorrectionLevel, + mask: u8, + ) -> DecoderResult { + self.version = version; + self.error_correction_level = Some(error_correction_level); + self.mask = mask; + self + } + + pub fn set_modes(&mut self, modes: Vec) { + self.modes = modes; + } + + pub fn set_structured_append(&mut self, info: StructuredAppendInfo) { + self.structured_append = Some(info); + } + + pub fn version(&self) -> u32 { + self.version + } + + pub fn error_correction_level(&self) -> Option { + self.error_correction_level + } + + pub fn mask(&self) -> u8 { + self.mask + } + + pub fn modes(&self) -> &[Mode] { + &self.modes + } + + pub fn structured_append(&self) -> Option { + self.structured_append + } + + pub fn into_content(self) -> ECIStringBuilder { + self.content + } } diff --git a/rxing-reader/src/decode.rs b/rxing-reader/src/decode.rs index f980317..a73903c 100644 --- a/rxing-reader/src/decode.rs +++ b/rxing-reader/src/decode.rs @@ -4,15 +4,68 @@ use anyhow::Result as RxingResult; use crate::{ Binarizer, BinaryBitmap, DecodeHints, Luma8LuminanceSource, - common::{GlobalHistogramBinarizer, HybridBinarizer}, + common::{ + Eci, GlobalHistogramBinarizer, HybridBinarizer, SymbologyIdentifier, + detect::{DecoderResult, StructuredAppendInfo}, + }, downscale_luma_buffer, - qrcode::QrReader, + qrcode::{ErrorCorrectionLevel, Mode, QrReader}, }; /// Pyramid downscale threshold and factor used by try-harder QR scanning. pub const PYRAMID_DOWNSCALE_THRESHOLD: usize = 500; pub const PYRAMID_DOWNSCALE_FACTOR: usize = 3; +/// Per-symbol output from the QR decode pipeline: payload bytes plus the +/// metadata extracted from the QR symbol (version, EC level, mask, the data +/// modes encountered, structured-append header when present, ECIs invoked, +/// and the symbology identifier the decoder produced). +#[derive(Debug, Clone)] +pub struct QrSymbol { + pub bytes: Vec, + pub version: u32, + pub error_correction_level: ErrorCorrectionLevel, + pub mask: u8, + pub modes: Vec, + pub structured_append: Option, + pub ecis: Vec, + pub symbology: SymbologyIdentifier, +} + +impl QrSymbol { + /// Move the metadata + payload out of a finished `DecoderResult`. Returns + /// `None` when the result is invalid or missing required QR metadata + /// (e.g. the EC level was never plumbed in). + pub(crate) fn from_decoder_result(decoder_result: DecoderResult) -> Option { + if !decoder_result.is_valid() { + return None; + } + let version = decoder_result.version(); + let error_correction_level = decoder_result.error_correction_level()?; + let mask = decoder_result.mask(); + let modes = decoder_result.modes().to_vec(); + let structured_append = decoder_result.structured_append(); + let content = decoder_result.into_content(); + let symbology = content.symbology; + let ecis: Vec = content + .eci_positions + .iter() + .map(|(eci, _, _)| *eci) + .collect(); + let bytes = content.into_bytes(); + Some(QrSymbol { + bytes, + version, + error_correction_level, + mask, + modes, + structured_append, + ecis, + symbology, + }) + } +} + pub fn rgba_to_luma(rgba: &[u8], width: usize, height: usize) -> Result, String> { let expected = width .checked_mul(height) @@ -44,7 +97,7 @@ pub fn decode_with_optional_invert( hints: &DecodeHints, max_number_of_symbols: usize, try_invert: bool, -) -> Vec> { +) -> Vec { let results = QrReader .decode_with_hints(bitmap, hints, max_number_of_symbols) .unwrap_or_default(); @@ -68,7 +121,7 @@ pub fn decode_one_layer( max_number_of_symbols: usize, try_invert: bool, close: bool, -) -> Vec> { +) -> Vec { if use_hybrid_binarizer { let mut bitmap = BinaryBitmap::new(HybridBinarizer::new(source)); if close && bitmap.close().is_err() { @@ -84,8 +137,10 @@ pub fn decode_one_layer( } } -/// Decode QR payload bytes from a luma image through the shared pyramid, -/// close-pass, binarizer, and optional-inversion pipeline. +/// Decode QR symbols from a luma image through the shared pyramid, +/// close-pass, binarizer, and optional-inversion pipeline. Returns one +/// [`QrSymbol`] per detected QR code, each carrying the payload bytes +/// alongside version/EC/mask/modes metadata. pub fn decode_qr_codes_luma( luma: &[u8], width: usize, @@ -94,7 +149,7 @@ pub fn decode_qr_codes_luma( try_invert: bool, use_hybrid_binarizer: bool, max_number_of_symbols: usize, -) -> RxingResult>> { +) -> RxingResult> { let hints = DecodeHints { try_harder }; if !try_harder { @@ -141,3 +196,19 @@ pub fn decode_qr_codes_luma( cur_h = next_h; } } + +#[cfg(test)] +mod tests { + use crate::{ + QrSymbol, + common::detect::DecoderResult, + qrcode::ErrorCorrectionLevel, + }; + + #[test] + fn rejects_invalid_decoder_result_even_when_format_metadata_exists() { + let decoder_result = DecoderResult::default().with_format(1, ErrorCorrectionLevel::L, 0); + + assert!(QrSymbol::from_decoder_result(decoder_result).is_none()); + } +} diff --git a/rxing-reader/src/json_view.rs b/rxing-reader/src/json_view.rs new file mode 100644 index 0000000..eca6dde --- /dev/null +++ b/rxing-reader/src/json_view.rs @@ -0,0 +1,114 @@ +//! Serde-serializable view of a [`QrSymbol`] for emission to JSON / JS. +//! +//! Shared by `rxing-cli` (writes a JSON array to stdout) and `rxing-wasm` +//! (serializes to a JS array of objects via `serde-wasm-bindgen`). Both +//! consumers enable rxing-reader's `serde` feature. +//! +//! The on-wire shape is: +//! ```json +//! { +//! "version": 4, +//! "error_correction_level": "M", +//! "mask": 3, +//! "modes": ["Byte"], +//! "structured_append": { "index": 0, "count": 2, "parity": 17 }, +//! "ecis": ["UTF8"], +//! "symbology": { "code": "Q", "modifier": "1", "ai_flag": "None" }, +//! "text": "hello" +//! } +//! ``` +//! Exactly one of `text` (UTF-8 valid payload) or `bytes_b64` (otherwise) is +//! present, flattened into the parent object. + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use serde::Serialize; + +use crate::{AIFlag, QrSymbol, StructuredAppendInfo, SymbologyIdentifier}; + +#[derive(Serialize)] +pub struct SymbolView { + pub version: u32, + pub error_correction_level: String, + pub mask: u8, + pub modes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub structured_append: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub ecis: Vec, + pub symbology: SymbologyView, + #[serde(flatten)] + pub payload: PayloadView, +} + +#[derive(Serialize)] +pub struct StructuredAppendView { + pub index: u8, + pub count: u8, + pub parity: u8, +} + +#[derive(Serialize)] +pub struct SymbologyView { + pub code: String, + pub modifier: String, + pub ai_flag: &'static str, +} + +#[derive(Serialize)] +#[serde(untagged)] +pub enum PayloadView { + Text { text: String }, + BytesB64 { bytes_b64: String }, +} + +pub fn symbol_to_view(symbol: QrSymbol) -> SymbolView { + let payload = match std::str::from_utf8(&symbol.bytes) { + Ok(s) => PayloadView::Text { text: s.to_string() }, + Err(_) => PayloadView::BytesB64 { + bytes_b64: BASE64.encode(&symbol.bytes), + }, + }; + SymbolView { + version: symbol.version, + error_correction_level: symbol.error_correction_level.to_string(), + mask: symbol.mask, + modes: symbol.modes.iter().map(|m| m.to_string()).collect(), + structured_append: symbol.structured_append.map(sa_view), + ecis: symbol.ecis.iter().map(|e| e.to_string()).collect(), + symbology: symbology_view(&symbol.symbology), + payload, + } +} + +pub fn sa_view(info: StructuredAppendInfo) -> StructuredAppendView { + StructuredAppendView { + index: info.index, + count: info.count, + parity: info.parity, + } +} + +pub fn symbology_view(sym: &SymbologyIdentifier) -> SymbologyView { + SymbologyView { + code: ascii_byte_string(sym.code), + modifier: ascii_byte_string(sym.modifier), + ai_flag: ai_flag_str(sym.ai_flag), + } +} + +pub fn ascii_byte_string(b: u8) -> String { + if b == 0 { + String::new() + } else { + String::from(b as char) + } +} + +pub fn ai_flag_str(f: AIFlag) -> &'static str { + match f { + AIFlag::None => "None", + AIFlag::GS1 => "GS1", + AIFlag::Aim => "Aim", + } +} diff --git a/rxing-reader/src/lib.rs b/rxing-reader/src/lib.rs index 61e2bd3..b3320a6 100644 --- a/rxing-reader/src/lib.rs +++ b/rxing-reader/src/lib.rs @@ -2,6 +2,9 @@ mod common; mod decode; mod error; +#[cfg(feature = "serde")] +pub mod json_view; + use binarizer::Binarizer; use binary_bitmap::BinaryBitmap; use decode_hints::DecodeHints; @@ -10,7 +13,9 @@ use luma_source::{Luma8LuminanceSource, downscale_luma_buffer}; use luminance_source::LuminanceSource; use point::{Point, point, point_i}; -pub use decode::{decode_qr_codes_luma, rgba_to_luma}; +pub use common::{AIFlag, Eci, SymbologyIdentifier, detect::StructuredAppendInfo}; +pub use decode::{QrSymbol, decode_qr_codes_luma, rgba_to_luma}; +pub use qrcode::{ErrorCorrectionLevel, Mode}; mod qrcode; diff --git a/rxing-reader/src/qrcode/format_information.rs b/rxing-reader/src/qrcode/format_information.rs index 0b2855a..aea746b 100644 --- a/rxing-reader/src/qrcode/format_information.rs +++ b/rxing-reader/src/qrcode/format_information.rs @@ -75,6 +75,21 @@ impl TryFrom for DataMask { } } +impl From for u8 { + fn from(value: DataMask) -> Self { + match value { + DataMask::Pattern000 => 0, + DataMask::Pattern001 => 1, + DataMask::Pattern010 => 2, + DataMask::Pattern011 => 3, + DataMask::Pattern100 => 4, + DataMask::Pattern101 => 5, + DataMask::Pattern110 => 6, + DataMask::Pattern111 => 7, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FormatInfoSource { Primary, diff --git a/rxing-reader/src/qrcode/mod.rs b/rxing-reader/src/qrcode/mod.rs index 09e78e4..dcf710d 100644 --- a/rxing-reader/src/qrcode/mod.rs +++ b/rxing-reader/src/qrcode/mod.rs @@ -6,8 +6,8 @@ mod reed_solomon; mod version; mod version_build_versions_arrays; -pub(crate) use error_correction_level::*; +pub use error_correction_level::ErrorCorrectionLevel; +pub use mode::Mode; pub(crate) use format_information::*; -pub(crate) use mode::*; pub(crate) use reader::QrReader; pub(crate) use version::*; diff --git a/rxing-reader/src/qrcode/mode.rs b/rxing-reader/src/qrcode/mode.rs index 02cf4ad..a495186 100644 --- a/rxing-reader/src/qrcode/mode.rs +++ b/rxing-reader/src/qrcode/mode.rs @@ -14,6 +14,8 @@ * limitations under the License. */ +use std::fmt; + use crate::Error; use anyhow::Result; @@ -150,3 +152,21 @@ impl TryFrom for Mode { Self::for_bits(value) } } + +impl fmt::Display for Mode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Mode::Terminator => "Terminator", + Mode::Numeric => "Numeric", + Mode::Alphanumeric => "Alphanumeric", + Mode::StructuredAppend => "StructuredAppend", + Mode::Byte => "Byte", + Mode::Fnc1FirstPosition => "Fnc1FirstPosition", + Mode::Eci => "Eci", + Mode::Kanji => "Kanji", + Mode::Fnc1SecondPosition => "Fnc1SecondPosition", + Mode::Hanzi => "Hanzi", + }; + f.write_str(s) + } +} diff --git a/rxing-reader/src/qrcode/reader.rs b/rxing-reader/src/qrcode/reader.rs index b110f94..5b491cb 100644 --- a/rxing-reader/src/qrcode/reader.rs +++ b/rxing-reader/src/qrcode/reader.rs @@ -3,7 +3,7 @@ mod decoder; mod detector; use crate::{ - Binarizer, BinaryBitmap, DecodeHints, + Binarizer, BinaryBitmap, DecodeHints, QrSymbol, common::detect::ConcentricPattern, }; @@ -24,14 +24,14 @@ impl QrReader { image: &mut BinaryBitmap, hints: &DecodeHints, max_symbols: usize, - ) -> anyhow::Result>> { + ) -> anyhow::Result> { let bin_img = image.black_matrix()?; let try_harder = hints.try_harder; let mut all_fps = find_finder_patterns(bin_img, try_harder); let mut used_fps: Vec = Vec::new(); - let mut results: Vec> = Vec::new(); + let mut results: Vec = Vec::new(); let all_fpsets = generate_finder_pattern_sets(&mut all_fps); for fp_set in all_fpsets { @@ -46,13 +46,13 @@ impl QrReader { if let Ok(detector_result) = detector_result { let decoder_result = decode(detector_result.bits()); if let Ok(decoder_result) = decoder_result - && decoder_result.is_valid() + && let Some(symbol) = QrSymbol::from_decoder_result(decoder_result) { used_fps.push(fp_set.bl); used_fps.push(fp_set.tl); used_fps.push(fp_set.tr); - results.push(decoder_result.into_bytes()); + results.push(symbol); if max_symbols != 0 && results.len() == max_symbols { break; @@ -63,4 +63,4 @@ impl QrReader { Ok(results) } -} \ No newline at end of file +} diff --git a/rxing-reader/src/qrcode/reader/decoder.rs b/rxing-reader/src/qrcode/reader/decoder.rs index 8b84134..195010d 100644 --- a/rxing-reader/src/qrcode/reader/decoder.rs +++ b/rxing-reader/src/qrcode/reader/decoder.rs @@ -9,7 +9,7 @@ use anyhow::Result; use crate::Error; use crate::common::{ AIFlag, BitMatrix, BitSource, CharacterSet, ECIStringBuilder, Eci, SymbologyIdentifier, - detect::{DecoderResult, to_fixed_len_string as padded_digits}, + detect::{DecoderResult, StructuredAppendInfo, to_fixed_len_string as padded_digits}, }; use crate::qrcode::reed_solomon::correct_qr_errors; use crate::qrcode::{ErrorCorrectionLevel, Mode, Version, VersionRef}; @@ -363,9 +363,15 @@ pub fn decode_bit_stream(bytes: &[u8], version: &Version) -> Result = Vec::new(); + let mut structured_append: Option = None; + let res: Result<()> = (|| { while !is_end_of_stream(&mut bits, version)? { let mode = Mode::codec_mode_for_bits(bits.read_bits(mode_bit_length)?)?; + if !modes_seen.contains(&mode) { + modes_seen.push(mode); + } match mode { Mode::Fnc1FirstPosition => { @@ -389,9 +395,14 @@ pub fn decode_bit_stream(bytes: &[u8], version: &Version) -> Result { - bits.read_bits(STRUCTURED_APPEND_INDEX_BITS)?; - bits.read_bits(STRUCTURED_APPEND_COUNT_BITS)?; - bits.read_bits(STRUCTURED_APPEND_PARITY_BITS)?; + let index = bits.read_bits(STRUCTURED_APPEND_INDEX_BITS)?; + let count = bits.read_bits(STRUCTURED_APPEND_COUNT_BITS)?; + let parity = bits.read_bits(STRUCTURED_APPEND_PARITY_BITS)?; + structured_append = Some(StructuredAppendInfo { + index: index as u8, + count: count as u8, + parity: parity as u8, + }); } Mode::Eci => { // Count doesn't apply to ECI @@ -430,7 +441,13 @@ pub fn decode_bit_stream(bytes: &[u8], version: &Version) -> Result Result { @@ -505,7 +522,12 @@ pub fn decode(bits: &BitMatrix) -> Result { } // decode the contents of that stream of bytes - decode_bit_stream(&result_bytes, version) + let decoder_result = decode_bit_stream(&result_bytes, version)?; + Ok(decoder_result.with_format( + version.number(), + format_info.error_correction_level, + u8::from(format_info.data_mask), + )) } struct DataBlock { diff --git a/rxing-reader/src/qrcode/reed_solomon.rs b/rxing-reader/src/qrcode/reed_solomon.rs index 2c30f6d..d0069e3 100644 --- a/rxing-reader/src/qrcode/reed_solomon.rs +++ b/rxing-reader/src/qrcode/reed_solomon.rs @@ -347,6 +347,9 @@ fn poly_add_scaled_in_place(field: &QrField, target: &mut Vec, term: &[u8], if scale == 0 || poly_is_zero(term) { return; } + + // Callers only subtract while the target degree is at least the term + // degree; a violation here is an implementation bug in polynomial division. debug_assert!(target.len() >= term.len()); // Multiplication by x^degree_diff appends low-degree zero terms, so the diff --git a/rxing-reader/tests/qr_decode.rs b/rxing-reader/tests/qr_decode.rs index 2aeeb07..e7f9ae9 100644 --- a/rxing-reader/tests/qr_decode.rs +++ b/rxing-reader/tests/qr_decode.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use std::path::PathBuf; use image::ImageReader; -use rxing_reader::{decode_qr_codes_luma, rgba_to_luma}; +use rxing_reader::{Mode, QrSymbol, decode_qr_codes_luma, rgba_to_luma}; fn load_image_as_rgba(relative_path: &str) -> (Vec, usize, usize) { let mut full = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -39,6 +39,16 @@ fn decode_combo( h: usize, (try_harder, try_invert, use_hybrid_binarizer): (bool, bool, bool), ) -> Option> { + decode_combo_symbol(rgba, w, h, (try_harder, try_invert, use_hybrid_binarizer)) + .map(|s| s.bytes) +} + +fn decode_combo_symbol( + rgba: &[u8], + w: usize, + h: usize, + (try_harder, try_invert, use_hybrid_binarizer): (bool, bool, bool), +) -> Option { let luma = rgba_to_luma(rgba, w, h).expect("luma"); decode_qr_codes_luma(&luma, w, h, try_harder, try_invert, use_hybrid_binarizer, 1) .expect("decode") @@ -50,14 +60,24 @@ fn decode_combo( fn decodes_qr_sample_png_in_every_combination() { let (rgba, w, h) = load_image_as_rgba("tests/fixtures/qr_sample.png"); for combo in ALL_COMBOS { - let bytes = decode_combo(&rgba, w, h, combo) + let symbol = decode_combo_symbol(&rgba, w, h, combo) .unwrap_or_else(|| panic!("qr_sample.png failed to decode for combo={:?}", combo)); assert_eq!( - bytes.as_slice(), + symbol.bytes.as_slice(), b"jfghjghjghfkghjkghj", "unexpected bytes for combo={:?}", combo ); + // Metadata pinned from a one-off observation against the fixture so + // future decoder regressions (mis-reading version / EC / mask / + // mode) trip the test. + assert_eq!(symbol.version, 2, "version for combo={:?}", combo); + assert_eq!( + symbol.modes, + vec![Mode::Byte], + "expected Byte mode for combo={:?}", + combo + ); } } @@ -81,7 +101,7 @@ fn decodes_qr_code_complex_png_in_every_combination() { fn decodes_real_fountain_byte_mode_fixture_in_every_combination() { let (rgba, w, h) = load_image_as_rgba("tests/fixtures/fountain_binary_real.png"); for combo in ALL_COMBOS { - let bytes = decode_combo(&rgba, w, h, combo).unwrap_or_else(|| { + let symbol = decode_combo_symbol(&rgba, w, h, combo).unwrap_or_else(|| { panic!( "fountain_binary_real.png failed to decode for combo={:?}", combo @@ -90,11 +110,17 @@ fn decodes_real_fountain_byte_mode_fixture_in_every_combination() { // Binary fountain payload starts with magic bytes 0xff 0xfd; the bytes-only // path returns the raw QR BYTE-mode payload without any UTF-8/Latin-1 mangling. assert_eq!( - &bytes[..2], + &symbol.bytes[..2], [0xff, 0xfd], "wrong magic prefix for combo={:?}", combo ); + assert!( + symbol.modes.contains(&Mode::Byte), + "expected Byte mode in {:?} for combo={:?}", + symbol.modes, + combo + ); } } @@ -584,7 +610,11 @@ fn rotated_and_inverted_qr_sample_requires_try_invert() { /// (0 = unlimited). fn decode_all(rgba: &[u8], w: usize, h: usize, count: usize) -> Vec> { let luma = rgba_to_luma(rgba, w, h).expect("luma"); - decode_qr_codes_luma(&luma, w, h, false, false, true, count).unwrap_or_default() + decode_qr_codes_luma(&luma, w, h, false, false, true, count) + .unwrap_or_default() + .into_iter() + .map(|s| s.bytes) + .collect() } const QR_COMPLEX_TEXT: &[u8] = b"https://qr-code-styling.com"; @@ -723,14 +753,20 @@ fn decodes_numeric_mode_qr_in_every_combination() { let (rgba, w, h) = load_image_as_rgba("tests/fixtures/qr_numeric.png"); let expected = b"012345678901234567890123456789012345"; for combo in ALL_COMBOS { - let bytes = decode_combo(&rgba, w, h, combo) + let symbol = decode_combo_symbol(&rgba, w, h, combo) .unwrap_or_else(|| panic!("qr_numeric.png failed to decode for combo={:?}", combo)); assert_eq!( - bytes.as_slice(), + symbol.bytes.as_slice(), expected.as_slice(), "unexpected bytes for combo={:?}", combo ); + assert_eq!( + symbol.modes, + vec![Mode::Numeric], + "expected Numeric mode only for combo={:?}", + combo + ); } } @@ -785,7 +821,7 @@ fn decodes_base45_alphanumeric_mode_qr_and_round_trips() { let expected_raw: &[u8] = b"\x00\xff\x01\xfe rxing-reader base45 round-trip \xde\xad\xbe\xef"; for combo in ALL_COMBOS { - let bytes = decode_combo(&rgba, w, h, combo).unwrap_or_else(|| { + let symbol = decode_combo_symbol(&rgba, w, h, combo).unwrap_or_else(|| { panic!( "qr_base45_alphanumeric.png failed to decode for combo={:?}", combo @@ -794,7 +830,7 @@ fn decodes_base45_alphanumeric_mode_qr_and_round_trips() { // Every byte returned by the decoder must be in the base45 charset — // if any wasn't, the encoder would have spilled into Byte mode and // this fixture would no longer cover `decode_alphanumeric_segment`. - for &b in &bytes { + for &b in &symbol.bytes { assert!( BASE45_ALPHABET.contains(&b), "decoded byte {:#x} outside base45 charset for combo={:?} \ @@ -803,11 +839,17 @@ fn decodes_base45_alphanumeric_mode_qr_and_round_trips() { combo ); } - let decoded = base45_decode(&bytes).unwrap_or_else(|| { + assert_eq!( + symbol.modes, + vec![Mode::Alphanumeric], + "expected Alphanumeric mode only for combo={:?}", + combo + ); + let decoded = base45_decode(&symbol.bytes).unwrap_or_else(|| { panic!( "base45 decode failed for combo={:?}; got {:?}", combo, - String::from_utf8_lossy(&bytes) + String::from_utf8_lossy(&symbol.bytes) ) }); assert_eq!( @@ -819,6 +861,16 @@ fn decodes_base45_alphanumeric_mode_qr_and_round_trips() { } } +// TODO: add a Kanji-mode QR fixture (`qr_kanji.png`) and a matching test +// asserting `symbol.modes == vec![Mode::Kanji]`. rxing-reader implements +// `decode_kanji_segment` (Shift_JIS double-byte → UTF-16BE bytes per QR +// spec) but there is no fixture exercising it end-to-end. Generation +// requires a QR encoder that forces Kanji mode (the `qrcode` crate's +// low-level `Bits::push_kanji_data` does this); commit the rendered PNG +// to `tests/fixtures/qr_kanji.png` and pin both the bytes and the modes +// metadata. The other supported data modes (Numeric, Alphanumeric, Byte) +// are already covered above. + #[test] fn base45_decode_round_trips_known_vectors() { // Sanity test on the in-test base45 decoder so a bug here doesn't masquerade diff --git a/rxing-wasm/Cargo.toml b/rxing-wasm/Cargo.toml index ec35e7a..ed78993 100644 --- a/rxing-wasm/Cargo.toml +++ b/rxing-wasm/Cargo.toml @@ -12,4 +12,5 @@ crate-type = ["cdylib", "rlib"] [dependencies] wasm-bindgen = "0.2" js-sys = "0.3" -rxing-reader = { path = "../rxing-reader" } +rxing-reader = { path = "../rxing-reader", features = ["serde"] } +serde-wasm-bindgen = "0.6" diff --git a/rxing-wasm/src/lib.rs b/rxing-wasm/src/lib.rs index f43af8c..97d3183 100644 --- a/rxing-wasm/src/lib.rs +++ b/rxing-wasm/src/lib.rs @@ -1,4 +1,5 @@ -use rxing_reader::{decode_qr_codes_luma, rgba_to_luma}; +use rxing_reader::json_view::{SymbolView, symbol_to_view}; +use rxing_reader::{QrSymbol, decode_qr_codes_luma, rgba_to_luma}; use wasm_bindgen::prelude::*; /// Run the decode pipeline once with `use_hybrid_binarizer`, then — when @@ -19,7 +20,7 @@ fn read_luma( use_hybrid_binarizer: bool, binarizer_fallback: bool, max_number_of_symbols: usize, -) -> Result>, JsValue> { +) -> Result, JsValue> { let primary = decode_qr_codes_luma( &luma, width, @@ -84,7 +85,9 @@ fn read_luma( /// Returns a JS `Array` of `Uint8Array`, one per detected symbol (empty when /// none are found). Returns `Err` only for invalid input (e.g. mismatched /// buffer length). Callers that need a string must decode the bytes -/// themselves (e.g. `new TextDecoder().decode(bytes)`). +/// themselves (e.g. `new TextDecoder().decode(bytes)`). For per-symbol +/// metadata (version, EC level, mask, modes, etc.) use +/// [`read_qr_codes_rgba_detailed`]. #[wasm_bindgen] #[allow(clippy::too_many_arguments)] // wasm-bindgen call shape; packing into a struct hurts the JS side pub fn read_qr_codes_rgba( @@ -101,7 +104,7 @@ pub fn read_qr_codes_rgba( let height = height as usize; let max_number_of_symbols = max_number_of_symbols as usize; let luma = rgba_to_luma(rgba, width, height).map_err(|m| JsValue::from_str(&m))?; - let payloads = read_luma( + let symbols = read_luma( luma, width, height, @@ -112,9 +115,61 @@ pub fn read_qr_codes_rgba( max_number_of_symbols, )?; - let out = js_sys::Array::new_with_length(payloads.len() as u32); - for (i, bytes) in payloads.into_iter().enumerate() { - out.set(i as u32, js_sys::Uint8Array::from(bytes.as_slice()).into()); + let out = js_sys::Array::new_with_length(symbols.len() as u32); + for (i, symbol) in symbols.into_iter().enumerate() { + out.set(i as u32, js_sys::Uint8Array::from(symbol.bytes.as_slice()).into()); } Ok(out) } + +/// Read every QR code in raw RGBA pixels, returning a JS `Array` of objects +/// — one per detected symbol — carrying both the payload and the QR +/// metadata extracted during decoding. +/// +/// Each entry has the shape: +/// ```js +/// { +/// version: 4, // 1..=40 +/// error_correction_level: "M", // "L"|"M"|"Q"|"H" +/// mask: 3, // 0..=7 +/// modes: ["Byte"], // unique data modes in encounter order +/// structured_append: { index, count, parity } | undefined, +/// ecis: ["UTF8", ...], +/// symbology: { code: "Q", modifier: "1", ai_flag: "None" }, +/// // exactly one of: +/// text: "hello" // when the payload decodes as UTF-8 +/// // or: +/// bytes_b64: "..." // when it does not +/// } +/// ``` +/// Argument semantics match [`read_qr_codes_rgba`]. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn read_qr_codes_rgba_detailed( + rgba: &[u8], + width: u32, + height: u32, + try_harder: bool, + try_invert: bool, + use_hybrid_binarizer: bool, + binarizer_fallback: bool, + max_number_of_symbols: u32, +) -> Result { + let width = width as usize; + let height = height as usize; + let max_number_of_symbols = max_number_of_symbols as usize; + let luma = rgba_to_luma(rgba, width, height).map_err(|m| JsValue::from_str(&m))?; + let symbols = read_luma( + luma, + width, + height, + try_harder, + try_invert, + use_hybrid_binarizer, + binarizer_fallback, + max_number_of_symbols, + )?; + + let views: Vec = symbols.into_iter().map(symbol_to_view).collect(); + serde_wasm_bindgen::to_value(&views).map_err(|e| JsValue::from_str(&e.to_string())) +}