diff --git a/Readme.md b/Readme.md
index d80533f..b77b114 100644
--- a/Readme.md
+++ b/Readme.md
@@ -429,7 +429,7 @@ Both can be used at the same time — they serve different purposes:
### Library Catalog (Koha)
- **Public search terminal** - a self-hosted library catalogue shown as one of the kiosk's pages (duration 0: it stays on screen), so visitors can search by title, author or subject and see whether a book is in
-- **Add books by scanning** - a Quick Add page: scan or type an ISBN and the book is looked up (Library of Congress via Z39.50, then Open Library) and added with the next barcode. Duplicates, "add another copy", manual entry and Undo are built in
+- **Add books by scanning** - a Quick Add page: scan or type an ISBN (or tap "Scan with camera" on a phone) and the book is looked up (Library of Congress via Z39.50, then Open Library) and added with the next barcode. Duplicates, "add another copy", manual entry and Undo are built in
- **Spine labels and barcodes** - Avery 5160/5167 and Demco spine templates, with Dewey call numbers split across lines
- **Docker is installed for you** if it is missing (`lib/docker.sh`); the stack is deployed from this checkout's `library/` to `/opt/kiosk-library`
- **Who can reach it** - Addons → Library Catalog → Network access: *this kiosk only* (ports bound to `127.0.0.1`) or *other devices on the network*. Docker bypasses `ufw`, so the bind address, not the firewall, is what limits access
diff --git a/library/README.md b/library/README.md
index 019e2b3..ee959be 100644
--- a/library/README.md
+++ b/library/README.md
@@ -51,8 +51,19 @@ for many of them.
The page beeps: high for added, double for a duplicate, low for not found. You can scan a
stack of books without looking at the screen.
-Phone camera scanning is not built in (browsers only allow the camera on HTTPS or
-`localhost`). A scanner-app that "types" the code into the box works.
+### Scanning with a phone camera
+
+Open the Add books page in your phone's browser and tap **Scan with camera**. Your phone's own camera app
+opens; take a photo with the barcode filling the frame, and the book is looked up and added just as if you had
+typed it. In "scan a pre-printed sticker" mode the same button appears on the sticker step.
+
+* It works over plain HTTP on both iPhone and Android: the photo is taken by the phone's camera app, shrunk on
+ the phone, and read on the server (`zbarimg`). Only JPEG and PNG photos are accepted.
+* It reads ISBN-13 and ISBN-10 barcodes, and UPC/EAN codes on discs and games.
+* Tips: get close so the barcode fills most of the frame, keep it flat and well lit, and hold steady. If it
+ can't read the photo you are told, and nothing is added.
+* A live viewfinder that reads continuously would need HTTPS (browsers only allow that for secure pages); a
+ Bluetooth scanner paired to the phone also works, since it types into the page.
## Barcodes: your choice
diff --git a/library/quickadd/Dockerfile b/library/quickadd/Dockerfile
index 2cb7623..5ff5b3f 100644
--- a/library/quickadd/Dockerfile
+++ b/library/quickadd/Dockerfile
@@ -5,7 +5,7 @@ FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
RUN apt-get update \
- && apt-get install -y --no-install-recommends ca-certificates python3 yaz tzdata \
+ && apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools tzdata \
&& rm -rf /var/lib/apt/lists/*
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
@@ -13,7 +13,7 @@ RUN apt-get update \
COPY certs/ /usr/local/share/ca-certificates/extra/
RUN update-ca-certificates
-RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null
+RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null
RUN useradd --system --uid 10001 --home-dir /app quickadd \
&& mkdir -p /app /data && chown quickadd /data
diff --git a/library/quickadd/app.py b/library/quickadd/app.py
index 3770a0a..b885c18 100644
--- a/library/quickadd/app.py
+++ b/library/quickadd/app.py
@@ -687,6 +687,58 @@ def op_assign(payload):
"last": fmt_barcode(first + len(items) - 1, s)}
+# ------------------------------------------------------------ photo scanning
+MAX_IMAGE = 12 * 1024 * 1024
+ZBAR_TIMEOUT = 20
+
+
+def image_kind(data):
+ """Only JPEG and PNG are accepted: the decoder goes through ImageMagick, which
+ has a long history of bugs in rarely used formats, so everything else is refused
+ before it gets anywhere near it."""
+ if data[:3] == b"\xff\xd8\xff":
+ return "jpg"
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
+ return "png"
+ return None
+
+
+def decode_photo(data, kind):
+ """Read barcodes from a photo with zbarimg. kind 'isbn' returns codes usable as an
+ ISBN/UPC (ISBNs first); kind 'sticker' returns anything Koha could store as a barcode."""
+ ext = image_kind(data)
+ if not ext:
+ raise ValueError("not a JPEG or PNG image")
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "photo." + ext)
+ with open(path, "wb") as f:
+ f.write(data)
+ try:
+ out = subprocess.run(["zbarimg", "--raw", "-q", path], capture_output=True, text=True,
+ timeout=ZBAR_TIMEOUT).stdout
+ except (subprocess.TimeoutExpired, OSError):
+ return []
+ codes = []
+ for line in out.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ if kind == "sticker":
+ ok = line if STICKER_RE.fullmatch(line) else None
+ else:
+ ok = clean_code(line)
+ # zbar reports a UPC-A (12 digits) as a 13-digit EAN with a leading zero.
+ # Use the 12 digits printed on the item, as a keyboard scanner would type
+ # them, so lookups and duplicate checks match.
+ if ok and len(ok) == 13 and ok[0] == "0":
+ ok = ok[1:]
+ if ok and ok not in codes:
+ codes.append(ok)
+ if kind != "sticker": # book ISBNs (978/979) before UPCs and short codes
+ codes.sort(key=lambda c: 0 if (len(c) == 13 and c[:3] in ("978", "979")) else 1 if len(c) == 10 else 2)
+ return codes
+
+
# ----------------------------------------------------------------------- HTTP
INDEX = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"), "rb").read()
@@ -755,6 +807,16 @@ class Handler(BaseHTTPRequestHandler):
if not self._guard():
return
try:
+ if self.path.split("?")[0] == "/api/scan":
+ length = int(self.headers.get("Content-Length", "0"))
+ if not 0 < length <= MAX_IMAGE:
+ return self._send(413, {"status": "error", "message": "That photo is too large."})
+ kind = "sticker" if "kind=sticker" in self.path else "isbn"
+ try:
+ codes = decode_photo(self.rfile.read(length), kind)
+ except ValueError:
+ return self._send(415, {"status": "error", "message": "Please send a JPEG or PNG photo."})
+ return self._send(200, {"status": "ok", "codes": codes})
length = int(self.headers.get("Content-Length", "0"))
if length > 20000:
return self._send(413, {"error": "too large"})
diff --git a/library/quickadd/index.html b/library/quickadd/index.html
index a3553bd..4cb7127 100644
--- a/library/quickadd/index.html
+++ b/library/quickadd/index.html
@@ -44,6 +44,7 @@
padding: 10px 16px; background: var(--card); color: var(--ink); font-weight: 600; }
button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
+ .cam-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0 0; }
#status { min-height: 1.5em; margin: 10px 2px; color: var(--muted); }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px;
padding: 14px; margin: 10px 0; display: flex; gap: 14px; }
@@ -98,9 +99,14 @@
+
+
+
+ Opens your phone's camera: fill the frame with the barcode and hold steady.
+
@@ -273,6 +279,49 @@
}
}
+ // ---- phone camera: take a photo, shrink it, have the server read the barcode
+ async function shrink(file, max = 2400) {
+ let bmp;
+ try { bmp = await createImageBitmap(file, { imageOrientation: 'from-image' }); }
+ catch (e) { bmp = await new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = URL.createObjectURL(file); }); }
+ const w = bmp.width, h = bmp.height, k = Math.min(1, max / Math.max(w, h));
+ const c = document.createElement('canvas'); c.width = Math.round(w * k); c.height = Math.round(h * k);
+ c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height);
+ return new Promise(ok => c.toBlob(ok, 'image/jpeg', 0.9));
+ }
+
+ // Opens the camera, resolves with the codes found ([] if none, null if cancelled).
+ function takePhoto(kind) {
+ return new Promise(resolve => {
+ const input = $('camfile');
+ input.onchange = async () => {
+ const f = input.files[0]; input.value = '';
+ if (!f) return resolve(null);
+ $('status').textContent = 'Reading the barcode…';
+ try {
+ const blob = await shrink(f);
+ const r = await fetch('/api/scan?kind=' + kind, { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: blob });
+ resolve(r.ok ? (await r.json()).codes : []);
+ } catch (e) { resolve([]); }
+ };
+ input.click();
+ });
+ }
+
+ function noBarcode() {
+ beep(SOUND.notfound);
+ $('status').textContent = '';
+ show('warn', el('div', { className: 'body' }, el('div', { className: 'title' }, "Couldn't read a barcode from that photo"),
+ el('div', { className: 'meta' }, 'Move closer so the barcode fills the frame, keep the phone steady and the barcode flat and well lit, then try again. You can also type the number.')));
+ }
+
+ $('cam').onclick = async () => {
+ const codes = await takePhoto('isbn');
+ if (codes === null) { $('status').textContent = ''; return; }
+ if (!codes.length) return noBarcode();
+ add(codes[0]);
+ };
+
function stickerPrompt(r) {
awaiting = true;
const input = el('input', { placeholder: 'Scan the barcode sticker', autocomplete: 'off', 'aria-label': 'Barcode sticker', inputMode: 'text' });
@@ -280,7 +329,14 @@
const cancel = el('button', { type: 'button' }, 'Cancel');
cancel.onclick = async () => { await api('/api/cancel', { token: r.token }); awaiting = false;
$('result').replaceChildren(); $('code').focus(); };
- form.append(input, el('div', { className: 'actions' }, el('button', { className: 'primary', type: 'submit' }, 'Add'), cancel));
+ const camBtn = el('button', { type: 'button' }, 'Scan sticker with camera');
+ camBtn.onclick = async () => {
+ const codes = await takePhoto('sticker');
+ if (codes === null) return;
+ if (!codes.length) { beep(SOUND.notfound); $('status').textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
+ handle(await api('/api/finish', { token: r.token, barcode: codes[0] }), r.code);
+ };
+ form.append(input, el('div', { className: 'actions' }, el('button', { className: 'primary', type: 'submit' }, 'Add'), camBtn, cancel));
form.onsubmit = async ev => {
ev.preventDefault();
const bc = input.value.trim(); if (!bc) return;
diff --git a/library/quickadd/tests/check_scan.py b/library/quickadd/tests/check_scan.py
new file mode 100644
index 0000000..44394cb
--- /dev/null
+++ b/library/quickadd/tests/check_scan.py
@@ -0,0 +1,39 @@
+"""Regression check for photo scanning: generate phone-like photos of real barcodes and make
+sure the running Quick Add reads them (at the size the page sends, 2400 px).
+
+ python3 -m venv /tmp/qa-venv && /tmp/qa-venv/bin/pip install zxing-cpp pillow
+ PHOTO_DIR=/tmp/qa-photos /tmp/qa-venv/bin/python tests/gen_photos.py
+ PHOTO_DIR=/tmp/qa-photos /tmp/qa-venv/bin/python tests/check_scan.py http://127.0.0.1:8082 admin PASSWORD
+"""
+import base64, io, json, os, sys, urllib.request
+from PIL import Image, ImageOps
+
+url, user, pw = sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3]
+D = os.environ.get("PHOTO_DIR", "/tmp/quickadd-photos")
+auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
+
+
+def scan(data, kind):
+ req = urllib.request.Request(f"{url}/api/scan?kind={kind}", data=data, method="POST",
+ headers={"Content-Type": "image/jpeg", "Authorization": auth})
+ with urllib.request.urlopen(req, timeout=60) as r:
+ return json.load(r)["codes"]
+
+
+def shrunk(path, m=2400): # what the page does before uploading
+ im = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
+ k = min(1, m / max(im.size))
+ im = im.resize((round(im.width * k), round(im.height * k)), Image.LANCZOS)
+ b = io.BytesIO(); im.save(b, "JPEG", quality=90); return b.getvalue()
+
+
+bad = 0
+for n in sorted(f for f in os.listdir(D) if f.endswith(".jpg")):
+ base = n[:-4]
+ exp_file = os.path.join(D, base + ".expect")
+ exp = open(exp_file).read() if os.path.exists(exp_file) else None
+ codes = scan(shrunk(os.path.join(D, n)), "sticker" if base.startswith("sticker") else "isbn")
+ ok = (codes[:1] == [exp]) if exp else (codes == [])
+ bad += not ok
+ print(("ok " if ok else "FAIL ") + f"{base:20s} expected {exp!s:14s} got {codes[:2]}")
+sys.exit(1 if bad else 0)
diff --git a/library/quickadd/tests/gen_photos.py b/library/quickadd/tests/gen_photos.py
new file mode 100644
index 0000000..a6d52c9
--- /dev/null
+++ b/library/quickadd/tests/gen_photos.py
@@ -0,0 +1,77 @@
+"""Generate phone-photo-like test images containing real barcodes."""
+import random, io, os
+import zxingcpp
+from PIL import Image, ImageDraw, ImageFilter, ImageEnhance
+
+OUT = os.environ.get("PHOTO_DIR", "/tmp/quickadd-photos")
+os.makedirs(OUT, exist_ok=True)
+random.seed(7)
+
+
+def barcode(fmt, text, scale=4, height=90):
+ bc = zxingcpp.write_barcode(fmt, text, width=0, height=height, quiet_zone=10)
+ img = Image.fromarray(bc.__array__() if hasattr(bc, "__array__") else bc).convert("RGB")
+ return img.resize((img.width * scale, img.height * scale), Image.NEAREST)
+
+
+def scene(code_img, w=3024, h=4032, tilt=0, blur=0.0, contrast=1.0, bright=1.0, noise=0, quality=72, exif=None, size_frac=0.45):
+ """Paste the barcode (on a white label) onto a coloured, busy 'book cover'."""
+ bg = Image.new("RGB", (w, h), (random.randint(20, 90), random.randint(30, 80), random.randint(60, 140)))
+ d = ImageDraw.Draw(bg)
+ for _ in range(60): # clutter: stripes, blobs, text-ish bars
+ x, y = random.randint(0, w), random.randint(0, h)
+ d.rectangle([x, y, x + random.randint(40, 600), y + random.randint(8, 60)],
+ fill=tuple(random.randint(0, 255) for _ in range(3)))
+ label = code_img.copy()
+ k = (w * size_frac) / label.width
+ label = label.resize((int(label.width * k), int(label.height * k)), Image.LANCZOS)
+ pad = Image.new("RGB", (label.width + 60, label.height + 60), "white")
+ pad.paste(label, (30, 30))
+ if tilt:
+ pad = pad.rotate(tilt, expand=True, resample=Image.BICUBIC, fillcolor="white")
+ bg.paste(pad, ((w - pad.width) // 2 + random.randint(-80, 80), (h - pad.height) // 2 + random.randint(-300, 300)))
+ if blur:
+ bg = bg.filter(ImageFilter.GaussianBlur(blur))
+ bg = ImageEnhance.Contrast(bg).enhance(contrast)
+ bg = ImageEnhance.Brightness(bg).enhance(bright)
+ if noise:
+ px = bg.load()
+ for _ in range(noise):
+ x, y = random.randint(0, w - 1), random.randint(0, h - 1)
+ v = random.randint(0, 255); px[x, y] = (v, v, v)
+ buf = io.BytesIO()
+ kw = {"quality": quality}
+ if exif is not None:
+ e = Image.Exif(); e[0x0112] = exif; kw["exif"] = e
+ bg.save(buf, "JPEG", **kw)
+ return buf.getvalue()
+
+
+E13 = zxingcpp.BarcodeFormat.EAN13
+UPCA = zxingcpp.BarcodeFormat.UPCA
+C39 = zxingcpp.BarcodeFormat.Code39
+
+cases = {
+ # name: (expected, bytes)
+ "isbn_clean": ("9780345339683", scene(barcode(E13, "9780345339683"))),
+ "isbn_tilt8": ("9780345339683", scene(barcode(E13, "9780345339683"), tilt=8, blur=1.2)),
+ "isbn_tilt_neg12": ("9780441172719", scene(barcode(E13, "9780441172719"), tilt=-12, blur=1.5, contrast=0.8)),
+ "isbn_dim_noisy": ("9780345339683", scene(barcode(E13, "9780345339683"), blur=1.4, contrast=0.7, bright=0.7, noise=60000, quality=55)),
+ "isbn_small": ("9780441172719", scene(barcode(E13, "9780441172719"), size_frac=0.22, blur=1.0)),
+ "isbn_exif_rot90": ("9780345339683", scene(barcode(E13, "9780345339683").rotate(90, expand=True), exif=6)),
+ "isbn_upside_down": ("9780345339683", scene(barcode(E13, "9780345339683").rotate(180, expand=True))),
+ "upc_a": ("036000291452", scene(barcode(UPCA, "036000291452"), tilt=5, blur=1.0)),
+}
+for name, (exp, data) in cases.items():
+ open(f"{OUT}/{name}.jpg", "wb").write(data)
+ open(f"{OUT}/{name}.expect", "w").write(exp)
+
+# sticker (Code 39, 7 digits) and a photo with no barcode at all
+open(f"{OUT}/sticker_c39.jpg", "wb").write(scene(barcode(C39, "0000042", scale=3), tilt=4, blur=1.0, size_frac=0.5))
+open(f"{OUT}/sticker_c39.expect", "w").write("0000042")
+blank = Image.new("RGB", (3024, 4032), (90, 60, 40)); ImageDraw.Draw(blank).rectangle([400, 900, 2600, 1300], fill=(200, 180, 60))
+b = io.BytesIO(); blank.save(b, "JPEG", quality=80); open(f"{OUT}/no_barcode.jpg", "wb").write(b.getvalue())
+print("wrote", len(os.listdir(OUT)) // 2, "photos to", OUT)
+for n in sorted(os.listdir(OUT)):
+ if n.endswith(".jpg"):
+ print(f" {n:22s} {os.path.getsize(os.path.join(OUT, n)) / 1e6:5.1f} MB")