# smart-package-tracker
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};
let id = TrackingId::generate()?; // PKG-9ED9285C
let barcode = Barcode::code128(&id)?;
let options = RenderOptions::default(); // 300 dpi, 13 mil, 25 mm tall
barcode.to_png_file("label.png", &options)?;
barcode.to_svg_file("label.svg", &options)?;
```
## Design
```text
TrackingId ──▶ Symbology::encode ──▶ Symbol ──▶ Renderer::render ──▶ bytes
(Code 128 | QR) (BitMatrix) (Png | Svg)
```
Encoding and rendering are independent, joined by a dimension-agnostic bit
grid. A linear barcode is a one-row `BitMatrix`; a matrix symbology such as QR
is a square one. Renderers consume the grid, not the symbology, so adding a
format means implementing `Symbology` and touching no rendering code — QR
support was added without a single change to the PNG or SVG renderers.
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
side. Omitting them is the most common cause of barcodes that look fine and
will not scan, so it is the default and must be opted out of.
* **Whole-pixel modules.** Module widths snap to integer pixels. A bar that
straddles a pixel boundary renders as a grey edge and costs you the contrast
a scanner depends on.
* **Physical units.** Lengths are millimetres, mils, or inches, converted
against an explicit DPI. PNG output carries a `pHYs` chunk, so a 300 dpi
label prints at 300 dpi.
* **Set C compression.** Digit runs encode two per symbol, which roughly halves
the width of numeric carrier tracking numbers.
* **Reproducible output.** Identical inputs produce byte-identical files.
* **No panics on bad input.** Every fallible operation returns `Result`, and
out-of-range accessors return empty rather than panicking.
## QR codes
The same identifier, as a QR code:
```rust
use smart_package_tracker::symbology::{Ecc, Qr, QrVersion};
use smart_package_tracker::{Barcode, RenderOptions};
let barcode = Barcode::qr("PKG-9ED9285C")?;
barcode.to_png_file("qr.png", &RenderOptions::default())?;
// Or configure error correction and pin the symbol size, so every label
// comes out the same dimensions regardless of payload length.
let barcode = Barcode::qr_with(
Qr::new().ecc(Ecc::Quartile).version(QrVersion::Fixed(6)),
"PKG-9ED9285C",
)?;
```
Error correction defaults to `Ecc::Medium` (~15% damage tolerance).
`Ecc::Quartile` or `Ecc::High` are worth the extra size for codes that will be
scuffed, curved around a parcel, or partly obscured. QR carries arbitrary
bytes, so unlike Code 128 it can encode payloads outside Latin-1.
Only full QR (versions 1–40) is supported. Micro QR is excluded: it holds very
little data, reader support is patchy, and it uses a 2-module quiet zone rather
than 4.
Rendered QR output is verified in the test suite by decoding it with
[`rqrr`](https://crates.io/crates/rqrr), an independent decoder derived from
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
familiar `PKG-9ED9285C` shape. Random IDs collide on the birthday bound:
| 32 bits | 8 hex chars | ~9,000 IDs | ~77,000 IDs |
| 48 bits | 12 hex chars | ~2.4 million | ~20 million |
| 64 bits | 16 hex chars | ~610 million | ~5.1 billion |
**Configure 64 bits for anything that will issue more than a few thousand IDs.**
Widening later is a data migration; choosing it now is one line:
```rust
use smart_package_tracker::{Checksum, IdGenerator};
let generator = IdGenerator::builder()
.prefix("PKG")
.entropy_bits(64)
.checksum(Checksum::Iso7064Mod37_36) // catches misreads and typos
.build()?;
let id = generator.generate()?;
generator.validate(&id)?;
```
Randomness alone never guarantees uniqueness. Keep a unique constraint at the
storage layer and retry on conflict.
## When something goes wrong
Every fallible call returns `Result<_, Error>`. `Error` is `#[non_exhaustive]`,
so match with a `_` arm. What each variant means in practice:
| `InvalidTrackingId` | A string from storage or user input is not `PREFIX-BODY`, or is not `A-Z`/`0-9` | The message names the first violation; IDs are uppercase-only by design |
| `IdPolicyMismatch` | The ID is well-formed but was not minted by this `IdGenerator` — wrong prefix, wrong body length, or a failed check character | Compare against the generator that issued it; a failed check character usually means a misread or typo |
| `InvalidIdConfig` | `IdGenerator::builder()` was given a bad prefix or entropy width | Prefix: 1–16 chars of `A-Z0-9`. Entropy: a multiple of 4, between 16 and 512 |
| `Entropy` | The OS randomness source is unavailable | The crate never falls back to a weaker source. Use `generate_from_entropy` with your own bytes |
| `InsufficientEntropy` | `generate_from_entropy` was handed too few bytes | Supply at least `generator.entropy_bytes()` |
| `Unencodable` | Code 128: the payload is outside Latin-1. QR: it does not fit the pinned version, or the version is not 1–40 | Use QR for non-Latin-1 payloads; raise the version or lower the ECC level |
| `EmptyPayload` | An empty string was passed to an encoder | Barcodes cannot encode nothing |
| `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*
correct and does not scan:
* **Quiet zones.** `QuietZone::None` and small `QuietZone::Modules(n)` values
are honoured exactly as asked. `QuietZone::Standard` is the only setting that
guarantees conformance.
* **Human-readable text.** The embedded font covers `0-9`, `A-Z`, space, and
`- . / * + $ % :`. Anything else prints as a hollow box. Check with
`hri_supports(text)` before it reaches a label, or disable HRI.
## Feature flags
| `std` | yes | File helpers and `std::error::Error`. Without it the crate is `no_std` + `alloc`. |
| `os-rng` | yes | Seed IDs from the OS CSPRNG. Turn off on targets `getrandom` does not support, such as bare metal; `IdGenerator::generate_from_entropy` still works there. |
| `code128` | yes | Code 128 encoding and decoding. |
| `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
| [`code128`](https://crates.io/crates/code128) | ISO/IEC 15417 encoding and decoding with automatic character-set selection |
| [`qrcode`](https://crates.io/crates/qrcode) | ISO/IEC 18004 QR encoding. Chosen over `fast_qr` and `qrcodegen` because it is dual MIT/Apache where both are MIT-only |
| [`png`](https://crates.io/crates/png) | PNG container. Used directly rather than via `image`, which would add a dozen codec dependencies to write a two-colour bitmap |
| [`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
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
of what fonts a print server has installed. It covers `0-9`, `A-Z`, space, and
`- . / * + $ % :`; other characters draw as a hollow box.
## Not implemented yet
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
This crate is `MIT OR Apache-2.0`.
* Code 128 is unencumbered — the symbology dates to 1981 and any patents have
long expired. ISO/IEC 15417 itself is a paywalled document; this
implementation cites it but reproduces none of its text.
* QR Code is a registered trademark of Denso Wave Incorporated. Denso Wave has
waived patent enforcement for ISO/IEC 18004-conformant use. The trademark is
acknowledged in the docs and kept out of the crate name.
* Issuing GS1-conformant identifiers such as SSCCs requires a GS1 company
prefix and membership. Carrier APIs require signed developer agreements.
Both are reasons to keep carrier integrations in separate crates.
## Minimum supported Rust version
1.85, set by `getrandom` 0.4 (edition 2024). The MSRV is tested in CI and
treated as a breaking change.
If you need a lower floor, pinning `getrandom = "0.3"` brings it down to 1.73
(`png` then sets the floor). That means tracking a superseded major of the
crate supplying the entropy for every tracking ID, which is why it is not the
default.
## Changelog
See [CHANGELOG.md](CHANGELOG.md).
## Development
```sh
cargo test --all-features
cargo clippy --all-features --all-targets -- -D warnings
cargo fmt --all --check
cargo run --example label # writes label*.png and label*.svg
```
[AGENTS.md](AGENTS.md) is the orientation guide for contributors and coding
agents: the repo map, the invariants a change must not break, where to add a new
symbology or renderer, and the release procedure. `CLAUDE.md` points at it.
## License
Licensed under either of [Apache License 2.0](LICENSE-APACHE) or
[MIT license](LICENSE-MIT) at your option.