diff --git a/Cargo.lock b/Cargo.lock index 31dc64d..e626879 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -767,23 +767,23 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rxing-cli" -version = "0.0.11" +version = "0.0.12" dependencies = [ "anyhow", "base64", "clap", "image", "rxing-reader", + "serde", "serde_json", "ureq", ] [[package]] name = "rxing-reader" -version = "0.0.11" +version = "0.0.12" dependencies = [ "anyhow", - "base64", "bitvec", "chrono", "codepage-437", @@ -792,17 +792,15 @@ dependencies = [ "multimap", "num", "once_cell", - "serde", "thiserror", ] [[package]] name = "rxing-wasm" -version = "0.0.11" +version = "0.0.12" dependencies = [ "js-sys", "rxing-reader", - "serde-wasm-bindgen", "wasm-bindgen", ] @@ -822,17 +820,6 @@ 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 12ac7b1..fbbd491 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "3" [workspace.package] -version = "0.0.11" +version = "0.0.12" [profile.release] codegen-units = 1 diff --git a/README.md b/README.md index 531d88c..4b1ec97 100644 --- a/README.md +++ b/README.md @@ -117,15 +117,23 @@ The CLI accepts a local file path or an http(s) URL and supports two output formats: ```sh -# Plain text (default). Caps decode at 1 result; non-UTF-8 payloads -# are printed as `base64:`. Exits 1 when no QR is found. +# Plain text (default). Caps decode at 1 result; invalid UTF-8 bytes are +# mapped to Latin-1 code points (byte = char). Exits 1 when no QR is found. rxing-cli qr.png # jfghjghjghfkghjkghj -# JSON. Returns every detection as an array; each entry has a -# `text` field (for UTF-8 payloads) or `bytes_b64` (for binary). +# JSON. Returns every detection as an array; each entry has a `text` +# field. Non-ASCII code points are escaped as `\uXXXX` so the wire +# bytes are pure ASCII (matches Python `json.dumps(ensure_ascii=True)`). rxing-cli --format json https://example.com/qr.png # [{"text":"https://qr-code-styling.com"},{"text":"jfghjghjghfkghjkghj"}] + +# JSON-only: pass --binary to emit every payload as base64 instead of +# text. The choice applies uniformly to every detection in the call, so +# consumers don't have to probe each entry for shape. Combining --binary +# with --format text (or no --format) is rejected with an error. +rxing-cli --format json --binary qr.png +# [{"bytes_b64":"aGVsbG8="}] ``` Decode-pipeline flags (`try_harder`, `try_invert`, binarizer choice, diff --git a/rxing-cli/Cargo.toml b/rxing-cli/Cargo.toml index 0dc3bff..4f422fd 100644 --- a/rxing-cli/Cargo.toml +++ b/rxing-cli/Cargo.toml @@ -10,10 +10,11 @@ name = "rxing-cli" path = "src/main.rs" [dependencies] -rxing-reader = { path = "../rxing-reader", features = ["serde"] } +rxing-reader = { path = "../rxing-reader" } 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/json_view.rs b/rxing-cli/src/json_view.rs new file mode 100644 index 0000000..30062ef --- /dev/null +++ b/rxing-cli/src/json_view.rs @@ -0,0 +1,200 @@ +//! Serde-serializable view of a [`QrSymbol`] for emission to JSON. +//! +//! Used by `rxing-cli` to write a JSON array to stdout. The wasm crate +//! does not depend on this module — it returns native JS objects with +//! `bytes: Uint8Array` directly, so text/base64 encoding is a +//! CLI-only concern. +//! +//! Payload representation is controlled per call via the `binary` toggle +//! passed to [`symbol_to_view`]; the choice is uniform across every +//! symbol in the call, so consumers know up front which field to read. +//! +//! - `binary = false` → flattened `text: String`. Valid UTF-8 payloads +//! decode directly; for invalid UTF-8, each byte is mapped to its +//! Latin-1 code point (byte = `char`), keeping the round-trip +//! lossless. The JSON output escapes non-ASCII as `\uXXXX`, so the +//! wire bytes are pure ASCII regardless of payload content. +//! - `binary = true` → flattened `bytes_b64: String` carrying base64 +//! (STANDARD alphabet) of the raw payload bytes. +//! +//! The on-wire shape (with `binary = false`) 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" +//! } +//! ``` + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use rxing_reader::{AIFlag, QrSymbol, StructuredAppendInfo, SymbologyIdentifier}; +use serde::Serialize; + +#[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, binary: bool) -> SymbolView { + let payload = if binary { + PayloadView::BytesB64 { + bytes_b64: BASE64.encode(&symbol.bytes), + } + } else { + let text = match std::str::from_utf8(&symbol.bytes) { + Ok(s) => s.to_string(), + Err(_) => symbol.bytes.iter().map(|&b| b as char).collect(), + }; + PayloadView::Text { text } + }; + 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", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rxing_reader::ErrorCorrectionLevel; + + fn symbol_with_bytes(bytes: Vec) -> QrSymbol { + QrSymbol { + bytes, + version: 1, + error_correction_level: ErrorCorrectionLevel::M, + mask: 0, + modes: Vec::new(), + structured_append: None, + ecis: Vec::new(), + symbology: SymbologyIdentifier::default(), + } + } + + #[test] + fn text_mode_ascii_payload() { + let view = symbol_to_view(symbol_with_bytes(b"hello".to_vec()), false); + match view.payload { + PayloadView::Text { text } => assert_eq!(text, "hello"), + PayloadView::BytesB64 { .. } => panic!("expected text variant"), + } + } + + #[test] + fn text_mode_valid_utf8_payload() { + let view = symbol_to_view(symbol_with_bytes("héllo".as_bytes().to_vec()), false); + match view.payload { + PayloadView::Text { text } => assert_eq!(text, "héllo"), + PayloadView::BytesB64 { .. } => panic!("expected text variant"), + } + } + + #[test] + fn text_mode_invalid_utf8_maps_byte_to_latin1_char() { + let raw = vec![0xFFu8, 0xFE, 0x00, b'A']; + let view = symbol_to_view(symbol_with_bytes(raw.clone()), false); + let text = match view.payload { + PayloadView::Text { text } => text, + PayloadView::BytesB64 { .. } => panic!("expected text variant"), + }; + let chars: Vec = text.chars().map(|c| c as u32).collect(); + let expected: Vec = raw.iter().map(|&b| b as u32).collect(); + assert_eq!(chars, expected); + } + + #[test] + fn binary_mode_emits_base64_for_ascii_payload() { + let view = symbol_to_view(symbol_with_bytes(b"hello".to_vec()), true); + match view.payload { + PayloadView::BytesB64 { bytes_b64 } => assert_eq!(bytes_b64, "aGVsbG8="), + PayloadView::Text { .. } => panic!("expected bytes_b64 variant"), + } + } + + #[test] + fn binary_mode_emits_base64_for_invalid_utf8() { + let raw = vec![0xFFu8, 0xFE, 0x00, b'A']; + let view = symbol_to_view(symbol_with_bytes(raw.clone()), true); + match view.payload { + PayloadView::BytesB64 { bytes_b64 } => { + assert_eq!(bytes_b64, BASE64.encode(&raw)); + } + PayloadView::Text { .. } => panic!("expected bytes_b64 variant"), + } + } +} diff --git a/rxing-cli/src/main.rs b/rxing-cli/src/main.rs index f847b9b..6a7c62f 100644 --- a/rxing-cli/src/main.rs +++ b/rxing-cli/src/main.rs @@ -3,12 +3,14 @@ use std::process::ExitCode; use std::time::Duration; use anyhow::{Context, Result}; -use base64::engine::general_purpose::STANDARD as BASE64; -use base64::Engine; use clap::{Parser, ValueEnum}; use image::ImageReader; -use rxing_reader::json_view::{SymbolView, symbol_to_view}; use rxing_reader::{QrSymbol, decode_qr_codes_luma, rgba_to_luma}; +use serde::Serialize; + +use crate::json_view::{SymbolView, symbol_to_view}; + +mod json_view; const MAX_HTTP_BODY: u64 = 64 * 1024 * 1024; @@ -25,6 +27,12 @@ struct Cli { /// Output format. `text` prints one payload; `json` prints all detections. #[arg(long, value_enum, default_value_t = Format::Text)] format: Format, + + /// JSON-only. Emit each symbol's payload as `bytes_b64` (base64 of + /// raw bytes) uniformly across every detection, instead of `text`. + /// Rejected when `--format` is anything other than `json`. + #[arg(long, default_value_t = false)] + binary: bool, } #[derive(ValueEnum, Clone, Copy, Debug)] @@ -45,6 +53,9 @@ fn main() -> ExitCode { fn run() -> Result { let cli = Cli::parse(); + if cli.binary && !matches!(cli.format, Format::Json) { + anyhow::bail!("--binary is only valid with --format json"); + } let bytes = load_bytes(&cli.source)?; let (rgba, w, h) = decode_image_bytes(&bytes) .with_context(|| format!("failed to decode image from {}", cli.source))?; @@ -53,7 +64,7 @@ fn run() -> Result { Format::Json => 0, }; let symbols = decode_symbols(&rgba, w, h, max)?; - render(symbols, cli.format) + render(symbols, cli.format, cli.binary) } fn load_bytes(source: &str) -> Result> { @@ -110,24 +121,132 @@ fn decode_symbols(rgba: &[u8], w: usize, h: usize, max: usize) -> Result, format: Format) -> Result { +fn render(symbols: Vec, format: Format, binary: bool) -> Result { match format { Format::Text => match symbols.into_iter().next() { None => Ok(ExitCode::from(1)), Some(symbol) => { - match std::str::from_utf8(&symbol.bytes) { - Ok(s) => println!("{s}"), - Err(_) => println!("base64:{}", BASE64.encode(&symbol.bytes)), - } + let text: String = match std::str::from_utf8(&symbol.bytes) { + Ok(s) => s.to_string(), + Err(_) => symbol.bytes.iter().map(|&b| b as char).collect(), + }; + println!("{text}"); Ok(ExitCode::SUCCESS) } }, Format::Json => { - let entries: Vec = symbols.into_iter().map(symbol_to_view).collect(); + let entries: Vec = symbols + .into_iter() + .map(|s| symbol_to_view(s, binary)) + .collect(); let mut stdout = io::stdout().lock(); - serde_json::to_writer(&mut stdout, &entries).context("writing JSON to stdout")?; + let mut ser = + serde_json::Serializer::with_formatter(&mut stdout, AsciiOnlyFormatter); + entries + .serialize(&mut ser) + .context("writing JSON to stdout")?; stdout.write_all(b"\n").context("writing trailing newline")?; Ok(ExitCode::SUCCESS) } } } + +/// `serde_json::Formatter` that escapes any non-ASCII code point as +/// `\uXXXX` (with a surrogate pair for code points above U+FFFF), so the +/// JSON wire output is pure ASCII regardless of payload content — +/// matching Python's `json.dumps(..., ensure_ascii=True)`. The default +/// formatter routes non-ASCII through `write_string_fragment` unchanged, +/// so that's the only override needed; quote/backslash/control escapes +/// retain default behavior. +struct AsciiOnlyFormatter; + +impl serde_json::ser::Formatter for AsciiOnlyFormatter { + fn write_string_fragment(&mut self, writer: &mut W, fragment: &str) -> io::Result<()> + where + W: ?Sized + io::Write, + { + let bytes = fragment.as_bytes(); + let mut start = 0; + for (idx, ch) in fragment.char_indices() { + if ch.is_ascii() { + continue; + } + if start < idx { + writer.write_all(&bytes[start..idx])?; + } + let code = ch as u32; + if code <= 0xFFFF { + write!(writer, "\\u{code:04x}")?; + } else { + let c = code - 0x10000; + let high = 0xD800 + (c >> 10); + let low = 0xDC00 + (c & 0x3FF); + write!(writer, "\\u{high:04x}\\u{low:04x}")?; + } + start = idx + ch.len_utf8(); + } + if start < bytes.len() { + writer.write_all(&bytes[start..])?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn to_ascii_json(value: &T) -> String { + let mut buf: Vec = Vec::new(); + { + let mut ser = serde_json::Serializer::with_formatter(&mut buf, AsciiOnlyFormatter); + value.serialize(&mut ser).unwrap(); + } + String::from_utf8(buf).unwrap() + } + + #[derive(Serialize)] + struct StrField<'a> { + text: &'a str, + } + + #[test] + fn ascii_payload_unchanged() { + let s = to_ascii_json(&StrField { text: "hello" }); + assert_eq!(s, r#"{"text":"hello"}"#); + assert!(s.is_ascii()); + } + + #[test] + fn latin1_supplement_escaped_as_u00xx() { + let s = to_ascii_json(&StrField { text: "héllo" }); + assert_eq!(s, r#"{"text":"h\u00e9llo"}"#); + assert!(s.is_ascii()); + } + + #[test] + fn bmp_non_ascii_escaped() { + let s = to_ascii_json(&StrField { text: "日本" }); + assert_eq!(s, r#"{"text":"\u65e5\u672c"}"#); + assert!(s.is_ascii()); + } + + #[test] + fn supplementary_plane_emits_surrogate_pair() { + // U+1F600 GRINNING FACE → high D83D, low DE00. + let s = to_ascii_json(&StrField { text: "\u{1F600}" }); + assert_eq!(s, r#"{"text":"\ud83d\ude00"}"#); + assert!(s.is_ascii()); + } + + #[test] + fn control_chars_still_use_default_escapes() { + // Default formatter behavior for "\n" and '"' must be preserved — + // our override only handles non-ASCII; quotes/backslash/control + // escapes come from the trait defaults. + let s = to_ascii_json(&StrField { + text: "a\n\"b", + }); + assert_eq!(s, r#"{"text":"a\n\"b"}"#); + } +} diff --git a/rxing-reader/Cargo.toml b/rxing-reader/Cargo.toml index a55c007..3fb3b5c 100644 --- a/rxing-reader/Cargo.toml +++ b/rxing-reader/Cargo.toml @@ -5,10 +5,6 @@ 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"] } @@ -19,8 +15,6 @@ 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/json_view.rs b/rxing-reader/src/json_view.rs deleted file mode 100644 index eca6dde..0000000 --- a/rxing-reader/src/json_view.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! 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 b3320a6..ca1bb66 100644 --- a/rxing-reader/src/lib.rs +++ b/rxing-reader/src/lib.rs @@ -2,9 +2,6 @@ mod common; mod decode; mod error; -#[cfg(feature = "serde")] -pub mod json_view; - use binarizer::Binarizer; use binary_bitmap::BinaryBitmap; use decode_hints::DecodeHints; diff --git a/rxing-wasm/Cargo.toml b/rxing-wasm/Cargo.toml index ed78993..ec35e7a 100644 --- a/rxing-wasm/Cargo.toml +++ b/rxing-wasm/Cargo.toml @@ -12,5 +12,4 @@ crate-type = ["cdylib", "rlib"] [dependencies] wasm-bindgen = "0.2" js-sys = "0.3" -rxing-reader = { path = "../rxing-reader", features = ["serde"] } -serde-wasm-bindgen = "0.6" +rxing-reader = { path = "../rxing-reader" } diff --git a/rxing-wasm/src/lib.rs b/rxing-wasm/src/lib.rs index b60d58c..bd7840b 100644 --- a/rxing-wasm/src/lib.rs +++ b/rxing-wasm/src/lib.rs @@ -1,7 +1,39 @@ -use rxing_reader::json_view::{SymbolView, symbol_to_view}; -use rxing_reader::{QrSymbol, decode_qr_codes_luma, rgba_to_luma}; +use rxing_reader::{AIFlag, QrSymbol, decode_qr_codes_luma, rgba_to_luma}; use wasm_bindgen::prelude::*; +/// Render the single-byte ID portion (`code` / `modifier`) of a +/// [`SymbologyIdentifier`] as a JS string: empty when the byte is zero, +/// otherwise the byte as an ASCII char. +fn ascii_byte_string(b: u8) -> String { + if b == 0 { + String::new() + } else { + String::from(b as char) + } +} + +fn ai_flag_str(f: AIFlag) -> &'static str { + match f { + AIFlag::None => "None", + AIFlag::GS1 => "GS1", + AIFlag::Aim => "Aim", + } +} + +#[wasm_bindgen(typescript_custom_section)] +const TS_DETAILED_QR_SYMBOL: &'static str = r#" +export interface DetailedQrSymbol { + version: number; + error_correction_level: "L" | "M" | "Q" | "H"; + mask: number; + modes: string[]; + structured_append?: { index: number; count: number; parity: number }; + ecis: string[]; + symbology: { code: string; modifier: string; ai_flag: "None" | "GS1" | "Aim" }; + bytes: Uint8Array; +} +"#; + /// Run the decode pipeline once with `use_hybrid_binarizer`, then — when /// `binarizer_fallback` is set and the first pass produced nothing — once /// more with the opposite binarizer. The two rxing binarizers fail on @@ -88,7 +120,7 @@ fn read_luma( /// 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] +#[wasm_bindgen(unchecked_return_type = "Uint8Array[]")] #[allow(clippy::too_many_arguments)] // wasm-bindgen call shape; packing into a struct hurts the JS side pub fn read_qr_codes_rgba( rgba: &[u8], @@ -122,9 +154,9 @@ pub fn read_qr_codes_rgba( 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. +/// Read every QR code in raw RGBA pixels, returning a JS `Array` of plain +/// objects — one per detected symbol — carrying both the raw payload and +/// the QR metadata extracted during decoding. /// /// Each entry has the shape: /// ```js @@ -136,14 +168,16 @@ pub fn read_qr_codes_rgba( /// 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 +/// bytes: Uint8Array(...) // raw payload bytes /// } /// ``` -/// Argument semantics match [`read_qr_codes_rgba`]. -#[wasm_bindgen] +/// +/// `bytes` is a `Uint8Array` view of the raw decoded payload — no UTF-8 +/// or base64 conversion happens on the wasm side. JS callers that need +/// text decode it themselves (`new TextDecoder().decode(entry.bytes)`), +/// which lets them pick the encoding and fallback strategy. Other +/// argument semantics match [`read_qr_codes_rgba`]. +#[wasm_bindgen(unchecked_return_type = "DetailedQrSymbol[]")] #[allow(clippy::too_many_arguments)] pub fn read_qr_codes_rgba_detailed( rgba: &[u8], @@ -170,6 +204,70 @@ pub fn read_qr_codes_rgba_detailed( 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())) + 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, symbol_to_js_object(symbol)?.into()); + } + Ok(out.into()) +} + +/// Build a plain JS object mirroring the fields of a [`QrSymbol`], with +/// `bytes` exposed as a `Uint8Array` rather than re-encoded. Returns the +/// object as a `js_sys::Object` so the caller can either treat it as +/// such or `.into()` it to `JsValue`. +fn symbol_to_js_object(symbol: QrSymbol) -> Result { + let obj = js_sys::Object::new(); + js_sys::Reflect::set(&obj, &"version".into(), &symbol.version.into())?; + js_sys::Reflect::set( + &obj, + &"error_correction_level".into(), + &symbol.error_correction_level.to_string().into(), + )?; + js_sys::Reflect::set(&obj, &"mask".into(), &u32::from(symbol.mask).into())?; + + let modes = js_sys::Array::new(); + for m in &symbol.modes { + modes.push(&m.to_string().into()); + } + js_sys::Reflect::set(&obj, &"modes".into(), &modes)?; + + if let Some(sa) = &symbol.structured_append { + let sa_obj = js_sys::Object::new(); + js_sys::Reflect::set(&sa_obj, &"index".into(), &u32::from(sa.index).into())?; + js_sys::Reflect::set(&sa_obj, &"count".into(), &u32::from(sa.count).into())?; + js_sys::Reflect::set(&sa_obj, &"parity".into(), &u32::from(sa.parity).into())?; + js_sys::Reflect::set(&obj, &"structured_append".into(), &sa_obj)?; + } + + let ecis = js_sys::Array::new(); + for e in &symbol.ecis { + ecis.push(&e.to_string().into()); + } + js_sys::Reflect::set(&obj, &"ecis".into(), &ecis)?; + + let sym = js_sys::Object::new(); + js_sys::Reflect::set( + &sym, + &"code".into(), + &ascii_byte_string(symbol.symbology.code).into(), + )?; + js_sys::Reflect::set( + &sym, + &"modifier".into(), + &ascii_byte_string(symbol.symbology.modifier).into(), + )?; + js_sys::Reflect::set( + &sym, + &"ai_flag".into(), + &ai_flag_str(symbol.symbology.ai_flag).into(), + )?; + js_sys::Reflect::set(&obj, &"symbology".into(), &sym)?; + + js_sys::Reflect::set( + &obj, + &"bytes".into(), + &js_sys::Uint8Array::from(symbol.bytes.as_slice()), + )?; + + Ok(obj) }