From 93c9425838585d9a13179223e90707b51acd2c7f Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 21 May 2026 05:36:51 +0000 Subject: [PATCH 1/6] feat: add binary output option for QR code payloads in CLI and WASM --- Cargo.lock | 1 + README.md | 15 +++- rxing-cli/Cargo.toml | 1 + rxing-cli/src/main.rs | 136 ++++++++++++++++++++++++++++++++-- rxing-reader/src/json_view.rs | 102 +++++++++++++++++++++++-- rxing-wasm/src/lib.rs | 29 ++++++-- 6 files changed, 258 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 31dc64d..fcc3fae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -774,6 +774,7 @@ dependencies = [ "clap", "image", "rxing-reader", + "serde", "serde_json", "ureq", ] diff --git a/README.md b/README.md index 531d88c..31703ff 100644 --- a/README.md +++ b/README.md @@ -117,15 +117,22 @@ 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"}] + +# 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. +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..a16c9cd 100644 --- a/rxing-cli/Cargo.toml +++ b/rxing-cli/Cargo.toml @@ -16,4 +16,5 @@ 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 = "1" serde_json = "1" diff --git a/rxing-cli/src/main.rs b/rxing-cli/src/main.rs index f847b9b..8726c5a 100644 --- a/rxing-cli/src/main.rs +++ b/rxing-cli/src/main.rs @@ -3,12 +3,13 @@ use std::process::ExitCode; use std::time::Duration; use anyhow::{Context, Result}; -use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; 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; const MAX_HTTP_BODY: u64 = 64 * 1024 * 1024; @@ -25,6 +26,13 @@ struct Cli { /// Output format. `text` prints one payload; `json` prints all detections. #[arg(long, value_enum, default_value_t = Format::Text)] format: Format, + + /// Emit payloads as `bytes_b64` (base64 of raw bytes) uniformly across + /// every symbol, instead of decoding to text. When unset, payloads + /// decode as UTF-8 (Latin-1 fallback for invalid bytes); JSON output + /// escapes non-ASCII as `\uXXXX`. + #[arg(long, default_value_t = false)] + binary: bool, } #[derive(ValueEnum, Clone, Copy, Debug)] @@ -53,7 +61,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 +118,136 @@ 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)), + if binary { + println!("base64:{}", 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(), + }; + 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/src/json_view.rs b/rxing-reader/src/json_view.rs index eca6dde..bf0c20a 100644 --- a/rxing-reader/src/json_view.rs +++ b/rxing-reader/src/json_view.rs @@ -4,7 +4,19 @@ //! (serializes to a JS array of objects via `serde-wasm-bindgen`). Both //! consumers enable rxing-reader's `serde` feature. //! -//! The on-wire shape is: +//! 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. CLI 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, @@ -17,8 +29,6 @@ //! "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; @@ -62,12 +72,17 @@ pub enum PayloadView { 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 { +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, @@ -112,3 +127,74 @@ pub fn ai_flag_str(f: AIFlag) -> &'static str { AIFlag::Aim => "Aim", } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::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-wasm/src/lib.rs b/rxing-wasm/src/lib.rs index b60d58c..96d4993 100644 --- a/rxing-wasm/src/lib.rs +++ b/rxing-wasm/src/lib.rs @@ -126,6 +126,19 @@ pub fn read_qr_codes_rgba( /// — one per detected symbol — carrying both the payload and the QR /// metadata extracted during decoding. /// +/// The `binary` flag selects the payload representation uniformly across +/// every entry returned by the call (so callers know which field to read +/// up front): +/// +/// - `binary = false` → each entry carries `text: string`. Valid UTF-8 +/// payloads decode directly; for invalid UTF-8 each byte maps to its +/// Latin-1 code point (byte = char), so the round-trip is lossless. +/// The JS value is a native string with real Unicode code points; the +/// `\uXXXX` ASCII escaping behavior is a JSON-wire concern handled by +/// `rxing-cli`, not by the wasm export. +/// - `binary = true` → each entry carries `bytes_b64: string`, base64 +/// (STANDARD alphabet) of the raw payload bytes. +/// /// Each entry has the shape: /// ```js /// { @@ -136,13 +149,13 @@ 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 +/// // when binary=false: +/// text: "hello" +/// // when binary=true: +/// bytes_b64: "aGVsbG8=" /// } /// ``` -/// Argument semantics match [`read_qr_codes_rgba`]. +/// Other argument semantics match [`read_qr_codes_rgba`]. #[wasm_bindgen] #[allow(clippy::too_many_arguments)] pub fn read_qr_codes_rgba_detailed( @@ -154,6 +167,7 @@ pub fn read_qr_codes_rgba_detailed( use_hybrid_binarizer: bool, binarizer_fallback: bool, max_number_of_symbols: u32, + binary: bool, ) -> Result { let width = width as usize; let height = height as usize; @@ -170,6 +184,9 @@ pub fn read_qr_codes_rgba_detailed( max_number_of_symbols, )?; - let views: Vec = symbols.into_iter().map(symbol_to_view).collect(); + let views: Vec = symbols + .into_iter() + .map(|s| symbol_to_view(s, binary)) + .collect(); serde_wasm_bindgen::to_value(&views).map_err(|e| JsValue::from_str(&e.to_string())) } From e98826ee790a43b113d09e272adcffb45e60377f Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 21 May 2026 05:46:04 +0000 Subject: [PATCH 2/6] refactor: remove serde-wasm-bindgen dependency and update QR code handling in WASM --- Cargo.lock | 12 ----- rxing-wasm/Cargo.toml | 1 - rxing-wasm/src/lib.rs | 123 +++++++++++++++++++++++++++++++----------- 3 files changed, 93 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fcc3fae..4be1506 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,7 +803,6 @@ version = "0.0.11" dependencies = [ "js-sys", "rxing-reader", - "serde-wasm-bindgen", "wasm-bindgen", ] @@ -823,17 +822,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/rxing-wasm/Cargo.toml b/rxing-wasm/Cargo.toml index ed78993..3b1dc93 100644 --- a/rxing-wasm/Cargo.toml +++ b/rxing-wasm/Cargo.toml @@ -13,4 +13,3 @@ crate-type = ["cdylib", "rlib"] wasm-bindgen = "0.2" js-sys = "0.3" 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 96d4993..1a2fd67 100644 --- a/rxing-wasm/src/lib.rs +++ b/rxing-wasm/src/lib.rs @@ -1,7 +1,21 @@ -use rxing_reader::json_view::{SymbolView, symbol_to_view}; +use rxing_reader::json_view::{ai_flag_str, ascii_byte_string}; use rxing_reader::{QrSymbol, decode_qr_codes_luma, rgba_to_luma}; use wasm_bindgen::prelude::*; +#[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 +102,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,22 +136,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. -/// -/// The `binary` flag selects the payload representation uniformly across -/// every entry returned by the call (so callers know which field to read -/// up front): -/// -/// - `binary = false` → each entry carries `text: string`. Valid UTF-8 -/// payloads decode directly; for invalid UTF-8 each byte maps to its -/// Latin-1 code point (byte = char), so the round-trip is lossless. -/// The JS value is a native string with real Unicode code points; the -/// `\uXXXX` ASCII escaping behavior is a JSON-wire concern handled by -/// `rxing-cli`, not by the wasm export. -/// - `binary = true` → each entry carries `bytes_b64: string`, base64 -/// (STANDARD alphabet) of the raw payload bytes. +/// 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 @@ -149,14 +150,16 @@ pub fn read_qr_codes_rgba( /// structured_append: { index, count, parity } | undefined, /// ecis: ["UTF8", ...], /// symbology: { code: "Q", modifier: "1", ai_flag: "None" }, -/// // when binary=false: -/// text: "hello" -/// // when binary=true: -/// bytes_b64: "aGVsbG8=" +/// bytes: Uint8Array(...) // raw payload bytes /// } /// ``` -/// Other 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], @@ -167,7 +170,6 @@ pub fn read_qr_codes_rgba_detailed( use_hybrid_binarizer: bool, binarizer_fallback: bool, max_number_of_symbols: u32, - binary: bool, ) -> Result { let width = width as usize; let height = height as usize; @@ -184,9 +186,70 @@ pub fn read_qr_codes_rgba_detailed( max_number_of_symbols, )?; - let views: Vec = symbols - .into_iter() - .map(|s| symbol_to_view(s, binary)) - .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) } From 1c7bd0a7508839d7ff1096c5e518185c98c1ffdc Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 21 May 2026 05:51:22 +0000 Subject: [PATCH 3/6] refactor: clean up dependencies and enhance JSON serialization for QR symbols --- Cargo.lock | 2 -- rxing-cli/Cargo.toml | 4 ++-- {rxing-reader => rxing-cli}/src/json_view.rs | 16 ++++++------- rxing-cli/src/main.rs | 5 +++- rxing-reader/Cargo.toml | 6 ----- rxing-reader/src/lib.rs | 3 --- rxing-wasm/Cargo.toml | 2 +- rxing-wasm/src/lib.rs | 24 +++++++++++++++++--- 8 files changed, 36 insertions(+), 26 deletions(-) rename {rxing-reader => rxing-cli}/src/json_view.rs (93%) diff --git a/Cargo.lock b/Cargo.lock index 4be1506..bde217c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -784,7 +784,6 @@ name = "rxing-reader" version = "0.0.11" dependencies = [ "anyhow", - "base64", "bitvec", "chrono", "codepage-437", @@ -793,7 +792,6 @@ dependencies = [ "multimap", "num", "once_cell", - "serde", "thiserror", ] diff --git a/rxing-cli/Cargo.toml b/rxing-cli/Cargo.toml index a16c9cd..4f422fd 100644 --- a/rxing-cli/Cargo.toml +++ b/rxing-cli/Cargo.toml @@ -10,11 +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 = "1" +serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/rxing-reader/src/json_view.rs b/rxing-cli/src/json_view.rs similarity index 93% rename from rxing-reader/src/json_view.rs rename to rxing-cli/src/json_view.rs index bf0c20a..30062ef 100644 --- a/rxing-reader/src/json_view.rs +++ b/rxing-cli/src/json_view.rs @@ -1,8 +1,9 @@ -//! Serde-serializable view of a [`QrSymbol`] for emission to JSON / JS. +//! Serde-serializable view of a [`QrSymbol`] for emission to JSON. //! -//! 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. +//! 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 @@ -11,7 +12,7 @@ //! - `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. CLI JSON output escapes non-ASCII as `\uXXXX`, so the +//! 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. @@ -32,10 +33,9 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; +use rxing_reader::{AIFlag, QrSymbol, StructuredAppendInfo, SymbologyIdentifier}; use serde::Serialize; -use crate::{AIFlag, QrSymbol, StructuredAppendInfo, SymbologyIdentifier}; - #[derive(Serialize)] pub struct SymbolView { pub version: u32, @@ -131,7 +131,7 @@ pub fn ai_flag_str(f: AIFlag) -> &'static str { #[cfg(test)] mod tests { use super::*; - use crate::ErrorCorrectionLevel; + use rxing_reader::ErrorCorrectionLevel; fn symbol_with_bytes(bytes: Vec) -> QrSymbol { QrSymbol { diff --git a/rxing-cli/src/main.rs b/rxing-cli/src/main.rs index 8726c5a..9951324 100644 --- a/rxing-cli/src/main.rs +++ b/rxing-cli/src/main.rs @@ -7,10 +7,13 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; 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; #[derive(Parser, Debug)] 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/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 3b1dc93..ec35e7a 100644 --- a/rxing-wasm/Cargo.toml +++ b/rxing-wasm/Cargo.toml @@ -12,4 +12,4 @@ crate-type = ["cdylib", "rlib"] [dependencies] wasm-bindgen = "0.2" js-sys = "0.3" -rxing-reader = { path = "../rxing-reader", features = ["serde"] } +rxing-reader = { path = "../rxing-reader" } diff --git a/rxing-wasm/src/lib.rs b/rxing-wasm/src/lib.rs index 1a2fd67..ee9d5c7 100644 --- a/rxing-wasm/src/lib.rs +++ b/rxing-wasm/src/lib.rs @@ -1,7 +1,25 @@ -use rxing_reader::json_view::{ai_flag_str, ascii_byte_string}; -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 { @@ -102,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(unchecked_return_type = "Uint8Array[]")] +#[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( rgba: &[u8], From e06eb24262244915d780ea1b173a7744b8642884 Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 21 May 2026 05:57:19 +0000 Subject: [PATCH 4/6] refactor: update wasm_bindgen attribute for read_qr_codes_rgba_detailed function --- rxing-wasm/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rxing-wasm/src/lib.rs b/rxing-wasm/src/lib.rs index ee9d5c7..bd7840b 100644 --- a/rxing-wasm/src/lib.rs +++ b/rxing-wasm/src/lib.rs @@ -120,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], From a63221ce74a69c6c9189f0981725cde9bf6fa13f Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 21 May 2026 05:58:00 +0000 Subject: [PATCH 5/6] bump version to 0.0.12 for rxing-cli, rxing-reader, and rxing-wasm --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bde217c..e626879 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -767,7 +767,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rxing-cli" -version = "0.0.11" +version = "0.0.12" dependencies = [ "anyhow", "base64", @@ -781,7 +781,7 @@ dependencies = [ [[package]] name = "rxing-reader" -version = "0.0.11" +version = "0.0.12" dependencies = [ "anyhow", "bitvec", @@ -797,7 +797,7 @@ dependencies = [ [[package]] name = "rxing-wasm" -version = "0.0.11" +version = "0.0.12" dependencies = [ "js-sys", "rxing-reader", 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 From c862fe94ac941f2a0d82807c3b47e7f8bde18c9d Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 21 May 2026 06:04:12 +0000 Subject: [PATCH 6/6] refactor: clarify binary output option in CLI and enforce JSON format requirement --- README.md | 7 ++++--- rxing-cli/src/main.rs | 26 +++++++++++--------------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 31703ff..4b1ec97 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,10 @@ rxing-cli qr.png rxing-cli --format json https://example.com/qr.png # [{"text":"https://qr-code-styling.com"},{"text":"jfghjghjghfkghjkghj"}] -# 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. +# 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="}] ``` diff --git a/rxing-cli/src/main.rs b/rxing-cli/src/main.rs index 9951324..6a7c62f 100644 --- a/rxing-cli/src/main.rs +++ b/rxing-cli/src/main.rs @@ -3,8 +3,6 @@ use std::process::ExitCode; use std::time::Duration; use anyhow::{Context, Result}; -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64; use clap::{Parser, ValueEnum}; use image::ImageReader; use rxing_reader::{QrSymbol, decode_qr_codes_luma, rgba_to_luma}; @@ -30,10 +28,9 @@ struct Cli { #[arg(long, value_enum, default_value_t = Format::Text)] format: Format, - /// Emit payloads as `bytes_b64` (base64 of raw bytes) uniformly across - /// every symbol, instead of decoding to text. When unset, payloads - /// decode as UTF-8 (Latin-1 fallback for invalid bytes); JSON output - /// escapes non-ASCII as `\uXXXX`. + /// 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, } @@ -56,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))?; @@ -126,15 +126,11 @@ fn render(symbols: Vec, format: Format, binary: bool) -> Result match symbols.into_iter().next() { None => Ok(ExitCode::from(1)), Some(symbol) => { - if binary { - println!("base64:{}", 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(), - }; - println!("{text}"); - } + 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) } },