Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ jobs:
- run: cargo test --all-features
- run: cargo test --no-default-features --features code128,svg
- run: cargo test --no-default-features --features qr,png
- run: cargo test --no-default-features --features scan
- run: cargo test --no-default-features

lint:
Expand All @@ -46,7 +47,8 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
targets: thumbv7em-none-eabihf
- run: cargo build --target thumbv7em-none-eabihf --no-default-features --features code128,svg
# Scanning is pure computation, so it has to keep working on bare metal.
- run: cargo build --target thumbv7em-none-eabihf --no-default-features --features code128,svg,scan

licenses:
runs-on: ubuntu-latest
Expand Down
68 changes: 52 additions & 16 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,31 @@ Orientation for developers and AI agents. Read this before changing code.

## What this crate is

A `no_std` + `alloc` library that mints package tracking IDs (`PKG-9ED9285C`)
and renders them as Code 128 or QR barcodes, to PNG or SVG. No network I/O, no
async, no carrier integrations — those are deliberately out of scope (see
"Boundaries").
A `no_std` + `alloc` library that mints package tracking IDs (`PKG-9ED9285C`),
renders them as Code 128 or QR barcodes to PNG or SVG, and reads Code 128 back
out of an image. No network I/O, no async, no carrier integrations — those are
deliberately out of scope (see "Boundaries").

## The one diagram that explains the layout

```text
TrackingId ──▶ Symbology::encode ──▶ Symbol ──▶ Renderer::render ──▶ bytes
src/id/ src/symbology/ (BitMatrix) src/render/

payload ◀── Decoder::decode ──── BitMatrix ◀── binarize ◀── GrayImage ◀── image
src/symbology/ src/scan/
```

The `BitMatrix` in the middle is the seam. Encoders never know about pixels;
renderers never know about symbologies. A linear barcode is a 1-row matrix, a
QR code is a square one — that is the *only* difference the renderers see.
The `BitMatrix` in the middle is the seam, and both directions cross it.
Encoders never know about pixels; renderers never know about symbologies; the
scanner never knows which symbology it is reading. A linear barcode is a 1-row
matrix, a QR code is a square one — that is the *only* difference the renderers
see.

**If a change makes a renderer match on `SymbologyKind`, the seam has been
broken. Push the difference into `Symbol`/`SymbologyKind` metadata instead.**
**If a change makes a renderer or the scanner match on `SymbologyKind`, the
seam has been broken. Push the difference into `Symbol`/`SymbologyKind`
metadata instead.** `required_quiet_zone()`, `is_linear()` and
`linear_character()` are what that looks like in practice.

## Repo map

Expand All @@ -33,15 +40,19 @@ broken. Push the difference into `Symbol`/`SymbologyKind` metadata instead.**
| `src/id/mod.rs` | `TrackingId` newtype + parsing/validation. `MAX_LEN`, the charset rule. |
| `src/id/generator.rs` | `IdGenerator`/builder: prefix, entropy width, checksum policy. |
| `src/id/checksum.rs` | ISO/IEC 7064 MOD 37,36 check character. |
| `src/symbology/mod.rs` | `BitMatrix`, `Symbol`, the `Symbology` trait, `SymbologyKind` metadata. |
| `src/symbology/mod.rs` | `BitMatrix`, `Symbol`, the `Symbology` and `Decoder` traits, `SymbologyKind`/`LinearCharacter` metadata. |
| `src/symbology/code128.rs` | Code 128 encode + decode, wrapping the `code128` crate. |
| `src/symbology/qr.rs` | QR encode, wrapping the `qrcode` crate. |
| `src/render/mod.rs` | `RenderOptions`, `Length`, `Color`, `QuietZone`, and **`Layout`** — the shared geometry calculation. |
| `src/render/png.rs` | Rasterises `Layout` into RGBA, wraps it in a PNG with a `pHYs` chunk. |
| `src/render/svg.rs` | Emits the same `Layout` as `<rect>` runs. |
| `src/render/hri.rs` | Embedded 5x7 bitmap font for the human-readable text line. |
| `src/scan/mod.rs` | `GrayImage`, `Scanner`, `Scan`. Image input, scan-line selection, and the row/column sweep. |
| `src/scan/binarize.rs` | Local adaptive threshold: pixels to dark/light modules. |
| `src/scan/linear.rs` | Pixel runs to modules, one character at a time. Symbology-agnostic. |
| `tests/integration.rs` | Consumer-level end-to-end tests (Code 128). |
| `tests/qr.rs` | QR output decoded back with `rqrr`, an independent decoder. |
| `tests/scan.rs` | Render, degrade the image the way reality does, scan it back. |
| `examples/label.rs` | Runnable end-to-end demo: `cargo run --example label`. |

## Invariants — do not break these silently
Expand All @@ -64,6 +75,11 @@ broken. Push the difference into `Symbol`/`SymbologyKind` metadata instead.**
6. **`no_std` + `alloc` core.** Only `std`, `png`, and `qr` may assume `std`.
Use `alloc::` imports, not `std::`, outside `#[cfg(feature = "std")]`.
7. **`#![forbid(unsafe_code)]`.** Non-negotiable.
8. **A scan never returns a guess.** Every payload the scanner returns has
passed the symbology's own check character. A wrong answer routes a parcel
to the wrong depot and looks correct doing it, which is worse than a miss —
so widening what the scanner will accept is a decision to make deliberately,
not a way to raise a hit rate.

## Commands

Expand All @@ -74,10 +90,10 @@ cargo fmt --all --check
cargo run --example label # writes label*.png / label*.svg

# no_std still builds
cargo build --target thumbv7em-none-eabihf --no-default-features --features code128,svg
cargo build --target thumbv7em-none-eabihf --no-default-features --features code128,svg,scan

# feature combinations compile in isolation (CI covers a subset)
for f in "" std serde os-rng code128 svg qr png; do
for f in "" std serde os-rng code128 svg qr png scan; do
cargo check --no-default-features --features "$f" --quiet || echo "FAIL [$f]"
done
```
Expand All @@ -92,6 +108,11 @@ Raising it is a breaking change.
gate it behind a feature. Touch no rendering code. QR was added this way.
* **A new renderer** — implement `Renderer` in `src/render/`, consume `Layout`,
compute no geometry of your own.
* **Making a symbology scannable** — implement `Decoder` for it and return a
`LinearCharacter` from `SymbologyKind::linear_character()`. `src/scan/` needs
no change; if it does, the metadata is not saying enough. A matrix symbology
cannot be scanned this way at all — a scan line through a QR grid is not a
code — and would need its own locator.
* **A new error case** — add a variant to `src/error.rs` with a lowercase,
actionable `#[error(...)]` message. The enum is `#[non_exhaustive]`, so this
is not a breaking change.
Expand All @@ -108,13 +129,28 @@ Raising it is a breaking change.
this) over one that asserts an implementation detail. Note that a decoder
handed a perfectly cropped image is far more forgiving than a scanner pointed
at a parcel — see the comment in `tests/qr.rs`.
* Scanning tests start from the crate's own renderer and degrade the image the
way reality does: rotated a quarter turn, inverted, dropped into a larger
scene, speckled, blurred. A scanner tested only on its own pristine output
has not been tested. Noise must come from a fixed seed — see `Lcg` in
`tests/scan.rs` — or the suite passes on the desk and fails in CI.

## Boundaries

Out of scope on purpose: image scanning, shipment events, carrier APIs, GS1
identifier issuance. They bring network I/O, async runtimes, and vendor licence
terms into a dependency graph that is currently 16 permissively-licensed crates
with no copyleft (`cargo deny check` enforces this).
Out of scope on purpose: shipment events, carrier APIs, GS1 identifier
issuance. They bring network I/O, async runtimes, and vendor licence terms into
a dependency graph that is permissively licensed with no copyleft (`cargo deny
check` enforces this).

Scanning is in scope only as far as it stays arithmetic. It reads rendered
labels, flatbed scans and screenshots — axis-aligned images — and adds no
dependencies. Camera-grade decoding (perspective correction, symbol
localisation, curved surfaces) is a scanning engine, and rebuilding one inside
a label-generation crate is the wrong shape; depend on `rxing` or similar.
QR scanning is out for the same reason plus a concrete cost: the only viable
decoder, `rqrr`, adds seven crates to the published graph. It stays a
dev-dependency, where it verifies our QR *output* without shipping to
consumers.

## Release — irreversible

Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Image scanning.** `scan::Scanner` reads a Code 128 barcode back out of an
image, behind the new default `scan` feature. `scan::scan_png` and
`scan_png_file` are the shorthand for PNG input; `GrayImage::from_luma`,
`from_rgb8` and `from_rgba8` take a pixel buffer from anywhere else.

It runs the rendering pipeline backwards and meets it at the same seam —
`image → GrayImage → binarize → BitMatrix → Decoder::decode → payload` — so
nothing in `src/scan/` knows which symbology it is reading. What it handles:
any module width, extra margin or a crop flush to the bars, the symbol
anywhere in a larger image, light-on-dark, uneven lighting, moderate speckle
and blur, and a label applied on its side, since columns are swept as well as
rows. Pixel runs are converted to modules one character at a time using the
new `SymbologyKind::linear_character` metadata, so a printed width that
drifts from nominal cannot accumulate into a misread.

Out of scope, and documented as such: rotation off the axes, perspective,
curved surfaces, and QR. This reads rendered labels, flatbed scans and
screenshots, not camera frames.

No new dependencies — the resolved graph is unchanged — and it builds
`no_std`.
- `symbology::Decoder`, the mirror of `Symbology`: modules back to a payload.
Implemented for `Code128`. `symbology::code128::decode` now delegates to it
and is otherwise unchanged.
- `SymbologyKind::linear_character`, describing how a linear symbology groups
bars and spaces into fixed-width characters. A unit test asserts it agrees
with `is_linear` for every variant, so the two cannot drift.
- `Error::InvalidImage` and `Error::NoSymbolFound`. A barcode that is present
but unreadable reports the latter rather than a guess: every payload returned
has passed the symbology's own check character.

## [0.3.0] - 2026-08-22

### Fixed
Expand Down
10 changes: 8 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ thiserror = { version = "2.0", default-features = false }
serde = { version = "1.0", default-features = false, features = ["derive", "alloc"], optional = true }

[features]
default = ["std", "os-rng", "code128", "qr", "png", "svg"]
default = ["std", "os-rng", "code128", "qr", "png", "svg", "scan"]

# Enables `std`-dependent APIs (file I/O, `std::error::Error`).
# Without it the crate is `no_std` + `alloc`: ID generation, Code 128 encoding
Expand Down Expand Up @@ -58,6 +58,12 @@ qr = ["dep:qrcode", "std"]
# SVG rendering. Zero additional dependencies.
svg = []

# Read barcodes back out of images. Zero additional dependencies: it decodes
# with the symbology support already in the crate, which is why it implies
# `code128` — the only symbology here that can be decoded. Works without `std`;
# with `png` it also reads PNG files directly.
scan = ["code128"]

# `Serialize`/`Deserialize` for `TrackingId` and friends.
serde = ["dep:serde"]

Expand All @@ -70,7 +76,7 @@ lto = true

[[example]]
name = "label"
required-features = ["std", "os-rng", "code128", "qr", "png", "svg"]
required-features = ["std", "os-rng", "code128", "qr", "png", "svg", "scan"]

[dev-dependencies]
# Integration tests decode our own PNG output to assert on pixels.
Expand Down
68 changes: 59 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# smart-package-tracker

Generate package tracking IDs and render them as Code 128 barcodes, in PNG and SVG.
Generate package tracking IDs, render them as Code 128 barcodes in PNG and SVG,
and read them back out of images.

```rust
use smart_package_tracker::{Barcode, RenderOptions, TrackingId};
Expand Down Expand Up @@ -30,6 +31,12 @@ Both renderers share a single `Layout` calculation, so PNG and SVG describe
identical geometry at identical physical size — an SVG preview matches what
the PNG prints.

Scanning is the same pipeline backwards, and meets it at the same bit grid:

```text
image ──▶ GrayImage ──▶ binarize ──▶ BitMatrix ──▶ Decoder::decode ──▶ payload
```

## What it does carefully

* **Specification-conformant quiet zones.** Code 128 requires 10 modules per
Expand Down Expand Up @@ -81,6 +88,40 @@ the `quirc` C library, so a symbol that encodes correctly but renders with a
missing quiet zone or a transposed grid fails the build. `rqrr` is a
dev-dependency and is not in the published dependency graph.

## Reading barcodes back

```rust
use smart_package_tracker::scan;

let found = scan::scan_png_file("label.png")?;
assert_eq!(found.payload(), "PKG-9ED9285C");
```

Any pixel buffer works, not just PNG — `GrayImage::from_luma`, `from_rgb8` and
`from_rgba8` take one directly, so a frame from a camera crate or an image
decoder needs no conversion beyond handing over its bytes.

**What it handles.** Any module width; extra margin or a crop flush to the
bars; the symbol anywhere in a larger image; light-on-dark as well as
dark-on-light; uneven lighting across the image; moderate speckle and blur; a
label applied on its side, because columns are scanned as well as rows; and a
printed width that drifts from nominal, because each character is measured
against its own width rather than against one estimate for the whole symbol.

**What it does not.** Rotation by anything other than a quarter turn,
perspective, and the curvature of a barcode wrapped around a parcel. Reading a
label off a photograph and reading one out of a rendered file are different
problems; this solves the second. If you are decoding camera frames, use a
dedicated scanning engine.

Code 128 only. QR encodes here but does not scan — there is no QR decoder in
the dependency graph, and adding one would put seven crates into it.

A miss and an unreadable barcode both report `NoSymbolFound` rather than a
guess. Nothing is returned that has not passed the symbology's own check
character, which is what keeps a smudge from becoming a plausible-looking
tracking number routed to the wrong depot.

## Tracking ID entropy

`TrackingId::generate()` defaults to 32 bits of randomness, which produces the
Expand Down Expand Up @@ -128,6 +169,8 @@ so match with a `_` arm. What each variant means in practice:
| `InvalidRenderOptions` | Non-positive DPI/width/height, or geometry over 20,000 px on an axis | The message names the computed size; reduce `module_width`, `height`, or `dpi` |
| `Render` | The PNG encoder failed | Rare; the message carries the underlying error |
| `Decode` | `Barcode::decode()` was called on a non-Code 128 symbol, or on an invalid pattern | There is no QR decoder in this crate |
| `InvalidImage` | A pixel buffer does not match its stated dimensions, the image exceeds 64 megapixels, or the PNG is unreadable | The message names the expected byte count or the limit |
| `NoSymbolFound` | Scanning found no barcode it could read | Check the symbology is Code 128 and the image is not rotated off-axis; see the scanning section for what is supported |
| `Io` | A `*_to_file` helper could not write | Standard `std::io::Error`; only present with the `std` feature |

Two failure modes deserve naming because they produce a file that *looks*
Expand All @@ -150,6 +193,7 @@ correct and does not scan:
| `qr` | yes | QR Code encoding. Implies `std`; the `qrcode` crate is not `no_std`. |
| `png` | yes | PNG rendering. Implies `std`. |
| `svg` | yes | SVG rendering. No extra dependencies. |
| `scan` | yes | Read Code 128 barcodes back out of images. No extra dependencies; implies `code128`, and works without `std`. |
| `serde` | no | `Serialize`/`Deserialize` for the public data types. |

## Dependencies
Expand All @@ -162,14 +206,17 @@ correct and does not scan:
| [`getrandom`](https://crates.io/crates/getrandom) | OS cryptographic RNG for ID entropy. Optional (`os-rng`), so bare-metal targets can supply their own |
| [`thiserror`](https://crates.io/crates/thiserror) | Error derive |

Scanning adds nothing to this list: it decodes with the Code 128 support
already present, and its binariser and scan lines are plain arithmetic.

All are `MIT OR Apache-2.0`, as is every one of the 16 crates in the resolved
transitive graph. There is no copyleft anywhere in it; `cargo deny check` in CI
enforces that.

The crate builds as `no_std` + `alloc`. ID generation, Code 128 encoding, and
SVG rendering all work without `std`; `cargo build --target
thumbv7em-none-eabihf --no-default-features --features code128,svg` is verified
in CI.
The crate builds as `no_std` + `alloc`. ID generation, Code 128 encoding and
decoding, SVG rendering, and scanning a pixel buffer all work without `std`;
`cargo build --target thumbv7em-none-eabihf --no-default-features --features
code128,svg,scan` is verified in CI.

Human-readable text is drawn with an embedded 5x7 bitmap font rather than a
font library, which keeps output identical between PNG and SVG and independent
Expand All @@ -178,10 +225,13 @@ of what fonts a print server has installed. It covers `0-9`, `A-Z`, space, and

## Not implemented yet

Image scanning, shipment events, and carrier integrations. The `Symbology` and
`Renderer` traits are the extension points for new formats. Carrier
integrations belong in separate crates so that network I/O, async runtimes, and
vendor licence terms stay out of this dependency graph.
Shipment events and carrier integrations. They belong in separate crates so
that network I/O, async runtimes, and vendor licence terms stay out of this
dependency graph.

Scanning covers linear symbologies on axis-aligned images; QR scanning and
camera-grade decoding are not here. The `Symbology`, `Decoder` and `Renderer`
traits are the extension points for new formats.

## Licensing notes

Expand Down
11 changes: 10 additions & 1 deletion examples/label.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
//!
//! Run with: `cargo run --example label`

use smart_package_tracker::scan;
use smart_package_tracker::symbology::{Ecc, Qr};
use smart_package_tracker::{
Barcode, Checksum, Color, IdGenerator, Length, QuietZone, RenderOptions,
Barcode, Checksum, Color, IdGenerator, Length, QuietZone, RenderOptions, TrackingId,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
Expand Down Expand Up @@ -48,6 +49,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
assert_eq!(barcode.decode()?, id.as_str());
println!("round-trip verified");

// And the *printed* label reads back, which is a stronger claim: this goes
// through the PNG decoder, the binariser and the scan lines rather than
// through the symbol still sitting in memory.
let scanned = scan::scan_png_file("label.png")?;
let recovered = TrackingId::parse(scanned.payload())?;
println!("scanned label.png: {recovered} ({})", scanned.kind());
assert_eq!(recovered, id);

// The same identifier as a QR code. Note that nothing about the rendering
// setup changes: a QR symbol is a square bit grid where Code 128 is a
// single row, and the renderers consume the grid either way.
Expand Down
5 changes: 4 additions & 1 deletion src/barcode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,14 @@ impl Barcode {
/// successful round trip is therefore evidence that the encoded geometry
/// is correct.
///
/// This reads the symbol in memory. To read one out of an image, see
/// [`scan`](crate::scan).
///
/// # Errors
///
/// Returns [`Error::Decode`](crate::Error::Decode) if this is not a
/// Code 128 barcode, or if the pattern is not valid Code 128. There is no
/// QR decoder in this crate; see the `scan` roadmap item.
/// QR decoder in this crate.
#[cfg(feature = "code128")]
pub fn decode(&self) -> Result<alloc::string::String> {
crate::symbology::code128::decode(&self.symbol)
Expand Down
Loading