managarten/packages/qr-export/src/encoder.ts
Till-JS e5109da732 feat(wallpaper-generator): add device wallpaper generation package
Add new @manacore/wallpaper-generator package for creating device
wallpapers from QR codes and images.

Features:
- 30 device presets (phones, tablets, desktops)
- 3 layout types (center, corner, pattern)
- 5 gradient presets + solid color backgrounds
- Browser (Canvas) and Node.js (Sharp) renderers
- Svelte WallpaperModal UI component

Integrations:
- @manacore/qr-export: toWallpaper() function
- @manacore/spiral-db: toWallpaper() function
- QRExportModal: "Als Wallpaper" button

Also includes the full @manacore/qr-export package which was
previously untracked.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 12:57:43 +01:00

231 lines
4.9 KiB
TypeScript

/**
* ManaQR Encoder/Decoder
*
* Encodes/decodes ManaQRExport data for QR codes using gzip compression.
*/
import pako from 'pako';
import type { ManaQRExport, EncodeResult, DecodeResult, DecodeError } from './types';
import { MANA_QR_LIMITS } from './types';
/** Prefix for ManaQR data (helps identify valid codes) */
export const MANA_QR_PREFIX = 'MANA1:';
/** Current format version */
export const MANA_QR_VERSION = 1;
/**
* Encode ManaQRExport data to a compressed string for QR codes
*/
export function encode(data: ManaQRExport): EncodeResult {
// Ensure version is set
const exportData: ManaQRExport = {
...data,
v: MANA_QR_VERSION,
ts: data.ts || Math.floor(Date.now() / 1000),
};
// Convert to JSON
const json = JSON.stringify(exportData);
// Compress with gzip
const compressed = pako.deflate(json);
// Convert to base64
const base64 = uint8ArrayToBase64(compressed);
// Add prefix
const result = MANA_QR_PREFIX + base64;
return {
data: result,
size: result.length,
fitsInQR: result.length <= MANA_QR_LIMITS.MAX_QR_BYTES,
};
}
/**
* Decode a ManaQR string back to ManaQRExport data
*/
export function decode(qrString: string): DecodeResult {
// Check prefix
if (!qrString.startsWith(MANA_QR_PREFIX)) {
return {
success: false,
error: 'INVALID_PREFIX',
message: `String must start with "${MANA_QR_PREFIX}"`,
};
}
// Extract base64 part
const base64 = qrString.slice(MANA_QR_PREFIX.length);
// Decode base64
let compressed: Uint8Array;
try {
compressed = base64ToUint8Array(base64);
} catch {
return {
success: false,
error: 'INVALID_BASE64',
message: 'Invalid base64 encoding',
};
}
// Decompress
let json: string;
try {
json = pako.inflate(compressed, { to: 'string' });
} catch {
return {
success: false,
error: 'DECOMPRESSION_FAILED',
message: 'Failed to decompress data',
};
}
// Parse JSON
let data: unknown;
try {
data = JSON.parse(json);
} catch {
return {
success: false,
error: 'INVALID_JSON',
message: 'Invalid JSON data',
};
}
// Validate structure
const validation = validateExport(data);
if (!validation.valid) {
return {
success: false,
error: validation.error,
message: validation.message,
};
}
return {
success: true,
data: data as ManaQRExport,
};
}
/**
* Estimate the encoded size without actually encoding
* Useful for checking if data will fit before encoding
*/
export function estimateSize(data: ManaQRExport): number {
const json = JSON.stringify(data);
// Rough estimate: gzip typically achieves 60-70% compression on JSON
// Base64 adds ~33% overhead
// Add prefix length
const estimatedCompressed = json.length * 0.35;
const estimatedBase64 = estimatedCompressed * 1.33;
return Math.ceil(estimatedBase64 + MANA_QR_PREFIX.length);
}
/**
* Check if data will likely fit in a single QR code
*/
export function willFitInQR(data: ManaQRExport): boolean {
return estimateSize(data) <= MANA_QR_LIMITS.MAX_QR_BYTES;
}
// --- Validation ---
interface ValidationResult {
valid: boolean;
error: DecodeError;
message: string;
}
function validateExport(data: unknown): ValidationResult {
if (typeof data !== 'object' || data === null) {
return {
valid: false,
error: 'INVALID_STRUCTURE',
message: 'Data must be an object',
};
}
const obj = data as Record<string, unknown>;
// Check version
if (obj.v !== MANA_QR_VERSION) {
return {
valid: false,
error: 'INVALID_VERSION',
message: `Unsupported version: ${obj.v}`,
};
}
// Check required fields
if (typeof obj.ts !== 'number') {
return {
valid: false,
error: 'INVALID_STRUCTURE',
message: 'Missing or invalid timestamp',
};
}
if (typeof obj.u !== 'object' || obj.u === null) {
return {
valid: false,
error: 'INVALID_STRUCTURE',
message: 'Missing user context',
};
}
if (!Array.isArray(obj.c)) {
return {
valid: false,
error: 'INVALID_STRUCTURE',
message: 'Contacts must be an array',
};
}
if (!Array.isArray(obj.e)) {
return {
valid: false,
error: 'INVALID_STRUCTURE',
message: 'Events must be an array',
};
}
if (!Array.isArray(obj.t)) {
return {
valid: false,
error: 'INVALID_STRUCTURE',
message: 'Todos must be an array',
};
}
return { valid: true, error: 'INVALID_STRUCTURE', message: '' };
}
// --- Base64 utilities (browser & Node compatible) ---
function uint8ArrayToBase64(bytes: Uint8Array): string {
if (typeof btoa === 'function') {
// Browser
return btoa(String.fromCharCode(...bytes));
}
// Node.js
return Buffer.from(bytes).toString('base64');
}
function base64ToUint8Array(base64: string): Uint8Array {
if (typeof atob === 'function') {
// Browser
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
// Node.js
return new Uint8Array(Buffer.from(base64, 'base64'));
}