feat: scanner, label sheet PDF generation, and print server client

- Barcode scanner with ML Kit (native) and BarcodeDetector API (web)
- Label sheet PDF generator with QR codes via bwip-js + jsPDF
- Print server REST client with health check
- 6 print server tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christopher Mühl 2026-02-26 15:38:33 +01:00
parent 1ef4661d9a
commit 0b382b9c5e
5 changed files with 373 additions and 0 deletions

View file

@ -0,0 +1,67 @@
<script lang="ts">
import { scanBarcode, requestCameraPermission, type ScanResult } from '$lib/scanning/detector';
let {
onscan,
onerror,
}: {
onscan: (result: ScanResult) => void;
onerror?: (error: Error) => void;
} = $props();
let scanning = $state(false);
let error = $state<string | null>(null);
async function startScan() {
scanning = true;
error = null;
try {
const hasPermission = await requestCameraPermission();
if (!hasPermission) {
error = 'Camera permission denied';
scanning = false;
return;
}
const result = await scanBarcode();
if (result) {
onscan(result);
} else {
error = 'No barcode detected';
}
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
error = err.message;
onerror?.(err);
} finally {
scanning = false;
}
}
</script>
<div class="flex flex-col items-center gap-4">
{#if error}
<div class="bg-red-500/20 text-red-400 px-4 py-2 rounded-lg text-sm w-full text-center">
{error}
</div>
{/if}
<button
onclick={startScan}
disabled={scanning}
class="w-full py-4 rounded-xl bg-blue-500 text-white font-medium text-lg
hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors min-h-[64px]"
>
{#if scanning}
<span class="animate-pulse">Scanning...</span>
{:else}
Scan Barcode
{/if}
</button>
<p class="text-xs text-slate-500 text-center">
Point your camera at a QR code, Code 128, or DataMatrix barcode
</p>
</div>

View file

@ -0,0 +1,72 @@
import bwipjs from 'bwip-js';
import jsPDF from 'jspdf';
export interface LabelSheetOptions {
cols?: number;
rows?: number;
cellWidth?: number;
cellHeight?: number;
marginX?: number;
marginY?: number;
qrSize?: number;
}
const DEFAULTS: Required<LabelSheetOptions> = {
cols: 5,
rows: 10,
cellWidth: 38,
cellHeight: 27,
marginX: 8,
marginY: 10,
qrSize: 18,
};
export async function generateLabelSheetPDF(
ids: string[],
options: LabelSheetOptions = {}
): Promise<Blob> {
const opts = { ...DEFAULTS, ...options };
const doc = new jsPDF('p', 'mm', 'a4');
const perPage = opts.cols * opts.rows;
for (let i = 0; i < ids.length; i++) {
const col = i % opts.cols;
const row = Math.floor(i / opts.cols) % opts.rows;
const page = Math.floor(i / perPage);
if (i > 0 && i % perPage === 0) {
doc.addPage();
}
const x = opts.marginX + col * opts.cellWidth;
const y = opts.marginY + row * opts.cellHeight;
// Generate QR code as PNG via canvas
const canvas = document.createElement('canvas');
bwipjs.toCanvas(canvas, {
bcid: 'qrcode',
text: `https://haus.toph.so/${ids[i]}`,
scale: 3,
includetext: false,
});
const qrDataUrl = canvas.toDataURL('image/png');
doc.addImage(qrDataUrl, 'PNG', x + 1, y + 1, opts.qrSize, opts.qrSize);
// ID text
doc.setFontSize(8);
doc.setFont('courier', 'normal');
doc.text(ids[i], x + opts.qrSize + 2, y + opts.qrSize / 2 + 1);
}
return doc.output('blob');
}
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

View file

@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { printLabels, checkPrintServer, type PrintServerConfig } from './printServer';
const config: PrintServerConfig = { baseUrl: 'http://printer.local:3030' };
beforeEach(() => {
vi.restoreAllMocks();
});
describe('printLabels', () => {
it('sends POST request with labels and format', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal('fetch', mockFetch);
const labels = [
{ id: 'abc1234', uri: 'https://haus.toph.so/abc1234' },
{ id: 'def5678', uri: 'https://haus.toph.so/def5678' },
];
await printLabels(config, labels, 'sheet');
expect(mockFetch).toHaveBeenCalledWith(
'http://printer.local:3030/api/print',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ labels, format: 'sheet' }),
})
);
});
it('defaults to sheet format', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal('fetch', mockFetch);
await printLabels(config, []);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.format).toBe('sheet');
});
it('throws on non-ok response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, statusText: 'Service Unavailable' })
);
await expect(printLabels(config, [])).rejects.toThrow('Print failed: Service Unavailable');
});
});
describe('checkPrintServer', () => {
it('returns true when server responds OK', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
expect(await checkPrintServer(config)).toBe(true);
});
it('returns false when server responds with error', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
expect(await checkPrintServer(config)).toBe(false);
});
it('returns false when fetch throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
expect(await checkPrintServer(config)).toBe(false);
});
});

View file

@ -0,0 +1,36 @@
export interface PrintServerConfig {
baseUrl: string;
}
export interface PrintLabel {
id: string;
uri: string;
}
export async function printLabels(
config: PrintServerConfig,
labels: PrintLabel[],
format: 'sheet' | 'strip' = 'sheet'
): Promise<void> {
const response = await fetch(`${config.baseUrl}/api/print`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ labels, format }),
});
if (!response.ok) {
throw new Error(`Print failed: ${response.statusText}`);
}
}
export async function checkPrintServer(config: PrintServerConfig): Promise<boolean> {
try {
const response = await fetch(`${config.baseUrl}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(3000),
});
return response.ok;
} catch {
return false;
}
}

View file

@ -0,0 +1,131 @@
import { Capacitor } from '@capacitor/core';
import { parseItemId } from './parser';
export interface ScanResult {
itemId: string;
rawValue: string;
format: string;
}
export async function scanBarcode(): Promise<ScanResult | null> {
if (Capacitor.isNativePlatform()) {
return scanWithMLKit();
} else {
return scanWithWebAPI();
}
}
async function scanWithMLKit(): Promise<ScanResult | null> {
const { BarcodeScanner, BarcodeFormat } = await import(
'@capacitor-mlkit/barcode-scanning'
);
const { barcodes } = await BarcodeScanner.scan({
formats: [
BarcodeFormat.QrCode,
BarcodeFormat.Code128,
BarcodeFormat.DataMatrix,
],
});
for (const barcode of barcodes) {
const id = parseItemId(barcode.rawValue ?? '');
if (id) {
return {
itemId: id,
rawValue: barcode.rawValue ?? '',
format: barcode.format?.toString() ?? 'unknown',
};
}
}
return null;
}
declare class BarcodeDetector {
constructor(options?: { formats: string[] });
detect(source: ImageBitmapSource): Promise<Array<{ rawValue: string; format: string }>>;
static getSupportedFormats(): Promise<string[]>;
}
async function scanWithWebAPI(): Promise<ScanResult | null> {
if (typeof window === 'undefined') return null;
const hasBarcodeAPI = 'BarcodeDetector' in window;
if (!hasBarcodeAPI) {
return null;
}
const detector = new BarcodeDetector({
formats: ['qr_code', 'code_128', 'data_matrix'],
});
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
});
const video = document.createElement('video');
video.srcObject = stream;
video.autoplay = true;
await video.play();
try {
// Attempt detection for up to 10 seconds at ~10fps
const maxAttempts = 100;
for (let i = 0; i < maxAttempts; i++) {
const codes = await detector.detect(video);
for (const code of codes) {
const id = parseItemId(code.rawValue);
if (id) {
return {
itemId: id,
rawValue: code.rawValue,
format: code.format,
};
}
}
await new Promise((r) => setTimeout(r, 100));
}
} finally {
stream.getTracks().forEach((t) => t.stop());
video.remove();
}
return null;
}
export async function checkCameraPermission(): Promise<boolean> {
if (Capacitor.isNativePlatform()) {
const { BarcodeScanner } = await import(
'@capacitor-mlkit/barcode-scanning'
);
const result = await BarcodeScanner.checkPermissions();
return result.camera === 'granted';
}
try {
const result = await navigator.permissions.query({
name: 'camera' as PermissionName,
});
return result.state === 'granted';
} catch {
return false;
}
}
export async function requestCameraPermission(): Promise<boolean> {
if (Capacitor.isNativePlatform()) {
const { BarcodeScanner } = await import(
'@capacitor-mlkit/barcode-scanning'
);
const result = await BarcodeScanner.requestPermissions();
return result.camera === 'granted';
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach((t) => t.stop());
return true;
} catch {
return false;
}
}