A branded, installable barcode scanner for iOS and Android that reads PDF417, Data Matrix, QR Code, MaxiCode and Aztec. It is a Progressive Web App: one static site, installed to the home screen, no app stores and no developer accounts. All decoding happens on the device — nothing is uploaded.
On a phone, open that link and choose Share → Add to Home Screen (iOS) or ⋮ → Add to Home screen (Android) to install it.
Before trusting a device in the field, open the self test on it — it decodes a known symbol of all five formats and reports what that particular phone can actually read.
The format list drove both decisions.
The free scanners built into the two platforms — Google ML Kit on Android and Apple's Vision framework on iOS — read QR, Data Matrix, PDF417 and Aztec, but neither supports MaxiCode. Requiring MaxiCode rules them out, and with them the easy native path.
That leaves ZXing-C++ (free, reads all five) or a commercial SDK such as Scandit
or Dynamsoft (robust MaxiCode, per-app annual licensing). This app uses
ZXing-C++ compiled to WebAssembly (zxing-wasm 3.1.3), which
runs identically in Safari and Chrome — so one codebase covers both platforms
with no native build, no Xcode and no Mac.
MaxiCode is verified working (see
Device self test), but it is the weakest of the five in
ZXing-C++. It is read-only in the library — there is no encoder —
and its detector wants a reasonably square-on, evenly lit, in-focus symbol. QR
and Data Matrix tolerate far more angle, blur and glare.
If field read rates on real UPS labels prove disappointing, the fix is to swap
the decode engine rather than rewrite the app: everything behind
app/js/decode.js is isolated to that one file. Budget for
a commercial SDK evaluation before assuming this is good enough for
high-throughput receiving.
npm installnpm run buildnpm startThen open http://localhost:8080. localhost counts as a secure context, so
the camera works without HTTPS during development.
| Command | What it does |
|---|---|
npm start |
Static dev server on port 8080 with correct .wasm MIME types |
npm run build |
All three generate steps below |
npm test |
Parser (29 cases) and CSV export (43 cases) suites, pure Node |
npm run verify |
Encodes a symbol of each format and round-trips it through the decoder |
Individually, when iterating:
| Command | What it does |
|---|---|
npm run test:parsers |
Parser suite only — scripts/test-parsers.mjs |
npm run test:csv |
CSV export suite only — scripts/test-history.mjs |
npm run build:vendor |
Copies the ZXing WASM + JS into app/vendor/ |
npm run build:icons |
Renders the app icons from an inline SVG |
npm run build:fixtures |
Regenerates the self-test barcodes and their manifest |
npm run build must be run at least once before the app will load — it copies
the WASM binary into app/vendor/.
Nothing in scripts/ ships. bwip-js and sharp are devDependencies used only
by the build and test tooling; the app itself has no runtime dependencies beyond
the vendored WASM.
Easiest path: open the deployed app — it is already on HTTPS, so nothing needs setting up.
To test uncommitted local changes on a phone, note that the camera requires a
secure context. localhost qualifies; a LAN address over plain HTTP does
not, and iOS is strict about this. Options:
- Android:
chrome://inspectport forwarding lets the phone reachlocalhost:8080directly as a secure origin. - Either platform: an HTTPS tunnel —
npx localtunnel --port 8080,cloudflared tunnel --url http://localhost:8080, or ngrok.
Stats in the top bar overlays a panel on the viewfinder showing the live scan rate and decode time per frame, plus the sensor resolution, format count and whether the device exposed a torch. It is the tool for answering "why is this phone slow to read labels" in the field — a low frame rate points at resolution or lighting, a high per-frame time at the decoder having to work.
The rate figures only exist while the frame loop is running. The panel shows the static facts immediately and appends the rate once there is one, rather than rendering blank while the camera is idle.
Open /dev/selftest.html on any device to decode a known symbol of every
supported format and check the parsed output against expected values. This is
how you tell "this phone cannot read MaxiCode" apart from "that label is
damaged" — the fixtures are generated by an independent encoder (BWIPP), not by
the decoder under test.
The app/dev/ directory is a diagnostic tool, not part of the app. Delete it
before deploying if you would rather not ship it.
Deployment is automatic. Pushing to main runs
.github/workflows/pages.yml, which gates on the
test suites and then publishes to GitHub Pages at
https://jackpetersoncg.github.io/PCGScan/.
GitHub Pages can only serve from the repository root or /docs, but the
deployable artifact is app/. The workflow therefore uploads app/ as the
Pages artifact, which serves it from the site root without restructuring the
repository. Pages already serves .wasm as application/wasm and
.webmanifest as application/manifest+json, both verified live.
On every deploy, bump CACHE in app/sw.js. The service
worker serves the app shell cache-first, so without a version bump installed
clients keep running the old code indefinitely. This is the single easiest
thing to forget and the most confusing to debug.
One quirk to expect when testing a fresh deploy in a plain browser tab: Pages
serves assets with Cache-Control: max-age=600, so for up to ten minutes a
reload can pair newly-fetched HTML with a still-cached app.js and the app will
look half-updated — new markup, old behaviour. Hard-reload to confirm before
chasing it as a bug. Installed users are not affected: the service worker serves
the whole shell from one versioned cache, so they never get a mixed set.
app/ is the entire deployable artifact — plain static files, no server-side
anything. It needs only HTTPS and the two MIME types above. Azure Static Web
Apps is a natural fit alongside PCG's Microsoft 365 tenancy if this ever needs
to move somewhere private; Cloudflare Pages and Netlify work identically. A
plain IIS site works too, but IIS does not know the .wasm MIME type by
default — you have to add it, or the decoder will refuse to load.
A decoded symbol is parsed into labelled fields and displayed, then saved to a local history which can be exported as CSV. (There is still no clipboard action; that was offered and not selected.)
Stored in localStorage, newest first, capped at 500 entries. Only
{text, format, contentType} plus a timestamp is persisted — the parsed view is
re-derived on display and on export, so parser improvements apply retroactively
to old scans and there is no second copy of the same data to keep in sync.
Two deliberate behaviours:
- Authenticator secrets are never saved. The parser already refuses to
display an
otpauth:payload; writing it tolocalStoragein the clear would undo that, so those scans are skipped and the user is told. - History can hold personal data. An AAMVA scan means a name, date of birth and address sitting on the device. The UI says so and offers Clear; the data never leaves the phone.
Tapping a history row re-renders that scan's full parsed card.
Every parsed field label seen across the export becomes its own column, so a
batch of GS1 scans yields real GTIN / Batch / lot number / Expiration date
columns rather than one opaque blob. Mixed batches are correspondingly sparse,
which is the honest representation of mixed data. Fixed columns come first
(timestamps in both local and UTC), with the raw payload and any parser warnings
last.
Four things the CSV layer gets right, all covered by
scripts/test-history.mjs (43 cases) because each
fails silently rather than loudly:
- Formula injection. A payload beginning
=,+,-or@executes as a formula when the file opens in Excel or Sheets. Barcode contents are untrusted input, so those cells are prefixed with an apostrophe to force text. - RFC 4180 quoting. An unescaped quote shifts every later column.
- Control characters become named tokens (
<GS>,<RS>). Raw0x1Dbytes corrupt the file for most readers, and newlines are tokenised too so no cell ever spans lines — AAMVA payloads are full of both. - Unique headers. The ISO 15434 parser emits a field called
Format, which collided with the symbology column; that column is nowSymbology, and any remaining collision is suffixed rather than duplicated.
Export prefers the share sheet (navigator.share) where available, because
on iOS that is markedly more reliable from an installed PWA than a download and
puts the file straight into Mail or Files. Elsewhere it falls back to a download
link. A UTF-8 BOM is prepended so Excel reads the encoding correctly instead of
mangling the em dashes in country names.
The parsers live in app/js/parsers/:
| Parser | Handles | Notable behaviour |
|---|---|---|
gs1.js |
GS1 application identifiers | AI length table (fixed vs variable), implied decimal points, YYMMDD dates with the GS1 century window, DD=00 end-of-month, ISO 3166 country names. Accepts both the raw FNC1 stream and the (01)… human-readable form. |
aamva.js |
Driver's licences / ID cards (PDF417) | Recovers elements by scanning rather than trusting the header's declared offsets, because real cards get them wrong. US MMDDCCYY vs Canadian CCYYMMDD dates, height/sex/eye-colour code expansion, expiry warning. |
iso15434.js |
MaxiCode + industrial [)> envelopes |
Format-ID dispatch (01 transportation, 05 delegates to GS1, 14/20 text). See below. |
generic.js |
URLs, Wi-Fi, vCard, calendar, mailto:/tel:/geo: |
Flags punycode homograph hosts, plain HTTP, bare-IP hosts and embedded credentials. Never auto-navigates. Refuses to display otpauth: secrets, which are also never written to history. |
Dispatch uses ZXing's own contentType (GS1, ISO15434) where available,
which is more reliable than sniffing the text.
Carrier field order inside an ISO 15434 format-01 record varies between
carriers and label revisions, and optional fields are sometimes simply absent —
which shifts every field after them. Rather than trust a fixed table,
iso15434.js validates the shape of each field it is about to label (a package
count looks like 1/1, an address-validation flag is Y or N). If the shapes
do not line up it stops labelling and says so, showing the fields
unlabelled with their positions instead. Labels it does apply from carrier
convention rather than from the data are marked inferred in the UI.
On a shipping document, a confidently wrong label is worse than no label.
A Theme row in the app offers Auto / Light / Dark. Auto follows the
phone's setting and tracks changes to it live; an explicit choice overrides the
phone in both directions and persists in localStorage. The override exists
because the right theme outdoors is a lighting question, not a system-settings
question — direct sun favours light regardless of what the phone says.
Every colour in app/css/app.css is a semantic token
(--text, --accent, --warn-bg); the brand hexes are raw ingredients that
only the token layer consumes. To retheme, change the tokens, not the rules.
Sizing has one token worth knowing about: --control-height is 44px, the tap
target both platform guidelines ask for, and every interactive row in the tuning
block and history list is pinned to it. That matters more than usual here
because the app gets used outdoors, sometimes with gloves on — WCAG's 24px floor
is not enough in a field tool. Keep new controls on the same token so the rows
stay even.
The theme is keyed off <html data-theme>, set to a concrete light or dark.
It is deliberately not a prefers-color-scheme media query: supporting an
explicit override through a media query needs the dark palette written twice
(once for @media dark, again for [data-theme="dark"]), and two copies of a
palette drift apart. Resolving the system preference in JS keeps one definition
per token.
That resolution happens in two places, which is intentional:
js/theme-init.js— a classic script in<head>, so it runs before first paint and nobody sees a flash of the wrong theme. Module scripts are deferred and would be too late. Shared with the self-test page so that themes correctly too.js/theme.js— the module handling the switch, persistence, and thematchMedialistener that keeps Auto current.
Keep the storage key and the two theme-color values in step across both.
The switch is initialised before the camera and engine guards in app.js, so it
still works when the app cannot scan at all.
The viewfinder is deliberately exempt. --stage-* and --scrim stay dark
in both themes, because that surface is live camera video rather than UI: a
bright scrim reflects off glossy labels, and the guide overlay has to stay
legible against whatever the camera sees. Both platforms' camera apps do the
same.
Contrast is verified, not eyeballed. After changing any token, run the audit in both schemes:
const { auditBoth } = await import('./dev/contrast-audit.js'); await auditBoth();It measures the rendered ratio of every themed component against its
effective background and prints a pass/fail table for both palettes, driving
data-theme itself so you do not have to touch the OS setting. All 30
components currently pass WCAG AA in both. It clicks the Stats toggle and needs
at least one saved scan, so the diagnostics panel and history rows exist to be
measured rather than reported absent.
Worth knowing: the warm grays straight off the brand sheet (Warm Gray 8
#8D827A) fail at 4.05:1 on white, and PCG orange fails badly as text at
2.4:1 — which is why muted text is darkened from the brand value, and
light-mode warnings use dark text on a pale orange tint, keeping orange for the
border and icon only.
app/ ← the entire deployable artifact
├── index.html
├── manifest.webmanifest
├── sw.js ← bump CACHE on every deploy
├── css/app.css ← PCG palette; guide-box offsets mirror scanner.js
├── js/
│ ├── app.js ← startup, controls, scan → parse → render cycle
│ ├── scanner.js ← camera, frame loop, guide → sensor mapping
│ ├── decode.js ← the only file that knows about ZXing
│ ├── render.js ← builds DOM nodes, never innerHTML
│ ├── history.js ← scan history + CSV export
│ ├── theme.js ← theme switch, persistence, system tracking
│ ├── theme-init.js ← classic script; applies theme before first paint
│ └── parsers/
├── vendor/zxing-wasm/ ← generated by `npm run build:vendor`
├── icons/ ← generated by `npm run build:icons`
└── dev/ ← diagnostics; safe to delete
├── selftest.html ← decodes all five formats on this device
├── contrast-audit.js ← measures rendered contrast in both themes
└── fixtures/ ← generated by `npm run build:fixtures`
scripts/ ← build + test tooling (never shipped)
Four things here are load-bearing and easy to break:
-
textMode: "Plain"indecode.js. The library's default renders control characters as printable escapes like<GS>, which destroys the byte structure every structured parser depends on. -
locateFileis overridden so the.wasmloads from our own origin. The default points at the jsDelivr CDN, which would break offline use and add a third-party request to every cold start. -
The guide-box constants.
GUIDEinscanner.jsand the.guideoffsets inapp.cssdescribe the same rectangle. The video is displayed withobject-fit: cover, somapGuideToVideo()reverses that centre crop to find the matching sensor pixels. If the two drift apart, the app decodes an area that is not the box the user is aiming with — which reads as "the scanner is just bad". Change them together. -
escapeCell()inhistory.jsdoes two jobs, and the second is not obvious: as well as RFC 4180 quoting it prefixes=,+,-and@to stop spreadsheets executing a scanned payload as a formula. A refactor that "simplifies" it to plain quoting reintroduces a live code-execution path into every exported file. The tests inscripts/test-history.mjswill fail if it goes.
Barcode content is untrusted input throughout: a QR code containing markup, or a
payload starting =HYPERLINK(...), is trivial to produce. So render.js builds
DOM nodes and assigns via textContent, decoded links are never auto-navigated,
and the CSV layer neutralises formulas. Treat any new surface that displays or
exports a payload the same way.
- MaxiCode is the weak format. See the caveat above.
- Torch is Android-only. iOS Safari exposes no torch API; the button is hidden when the capability is absent rather than shown dead.
- Brand fonts need the network. Prompt and Montserrat load from Google Fonts, so offline use falls back to the system stack. Self-host WOFF2 files if brand-exact type offline matters.
- The install splash screen is always dark. A web manifest has no
per-scheme form of
background_color, and it cannot see the in-app theme choice at all, so light-theme users get a brief dark splash before the app paints. Dark was chosen over white because the green icon reads better on it, and a white flash at night is more jarring than a dark one in daylight. iOS'sapple-mobile-web-app-status-bar-stylehas the same limitation, which is why it is set todefaultand the status bar is tinted from atheme-colortag thattheme.jskeeps current instead. - History is capped at 500 scans and lives in
localStorage, so it is per-device and per-browser: it does not sync, and clearing site data clears it. Export before wiping anything. Move to IndexedDB if scans ever need to carry images or run to thousands. - The iOS share-sheet export path is unverified. Desktop takes the download
fallback, which is tested;
navigator.sharewith a file attachment could not be exercised here. Worth confirming on a real iPhone the first time you export. - No clipboard action. Offered and not selected; a per-field copy button is a
small addition to
render.jsif it turns out to be wanted. - The GS1 AI table is a working subset, covering trade and logistics
identifiers rather than all ~450 AIs. Unrecognised AIs are reported as such,
not silently dropped. Extend the table in
gs1.js. - iOS PWA camera performance trails a native app. If throughput becomes the
binding constraint, the parsers and UI port directly to a React Native shell
— only
scanner.jsanddecode.jswould be replaced.