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:
parent
1ef4661d9a
commit
0b382b9c5e
5 changed files with 373 additions and 0 deletions
67
src/lib/components/Scanner.svelte
Normal file
67
src/lib/components/Scanner.svelte
Normal 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>
|
||||||
72
src/lib/printing/labelSheet.ts
Normal file
72
src/lib/printing/labelSheet.ts
Normal 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);
|
||||||
|
}
|
||||||
67
src/lib/printing/printServer.test.ts
Normal file
67
src/lib/printing/printServer.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/lib/printing/printServer.ts
Normal file
36
src/lib/printing/printServer.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/lib/scanning/detector.ts
Normal file
131
src/lib/scanning/detector.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue