import type { LabelShape, SheetSpec } from '../core/types'; export const CUSTOM_SHEET_PREFIX = 'custom:'; const STORAGE_KEY = 'label-maker:custom-sheets'; const LAST_SHEET_STORAGE_KEY = 'label-maker:last-sheet-id'; // `localStorage` is undefined during prerendering and may throw in private // browsing or when a quota is exceeded, so every access is guarded. function readStorage(key: string): string | null { try { return typeof localStorage === 'undefined' ? null : localStorage.getItem(key); } catch { return null; } } function writeStorage(key: string, value: string): void { try { if (typeof localStorage === 'undefined') return; localStorage.setItem(key, value); } catch { // Persistence is a nice-to-have; failing silently beats breaking the app. } } const LABEL_SHAPES: LabelShape[] = ['rectangle', 'square', 'circle', 'oval']; function isSheetSpec(value: unknown): value is SheetSpec { if (typeof value !== 'object' || value === null) return false; const s = value as Record; return ( typeof s.id === 'string' && typeof s.brand === 'string' && typeof s.code === 'string' && typeof s.pageWidthMm === 'number' && typeof s.pageHeightMm === 'number' && typeof s.pageName === 'string' && typeof s.shape === 'string' && LABEL_SHAPES.includes(s.shape as LabelShape) && typeof s.cornerRadiusMm === 'number' && typeof s.columns === 'number' && typeof s.rows === 'number' && typeof s.labelWidthMm === 'number' && typeof s.labelHeightMm === 'number' && typeof s.marginLeftMm === 'number' && typeof s.marginTopMm === 'number' && typeof s.pitchXMm === 'number' && typeof s.pitchYMm === 'number' ); } /** Reads user-defined sheets from storage, dropping anything that fails validation. */ export function loadCustomSheets(): SheetSpec[] { const raw = readStorage(STORAGE_KEY); if (!raw) return []; try { const parsed: unknown = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter(isSheetSpec) : []; } catch { return []; } } function persist(sheets: SheetSpec[]): void { writeStorage(STORAGE_KEY, JSON.stringify(sheets)); } export function saveCustomSheet(sheet: SheetSpec): SheetSpec[] { const next = [...loadCustomSheets().filter((s) => s.id !== sheet.id), sheet]; persist(next); return next; } export function deleteCustomSheet(id: string): SheetSpec[] { const next = loadCustomSheets().filter((s) => s.id !== id); persist(next); return next; } export function loadLastSheetId(): string | null { return readStorage(LAST_SHEET_STORAGE_KEY); } export function saveLastSheetId(id: string): void { writeStorage(LAST_SHEET_STORAGE_KEY, id); } const STANDARD_PAGE_SIZES: { name: string; widthMm: number; heightMm: number }[] = [ { name: 'A4', widthMm: 210, heightMm: 297 }, { name: 'Letter', widthMm: 215.9, heightMm: 279.4 } ]; const STANDARD_PAGE_TOLERANCE_MM = 0.5; function derivePageName(widthMm: number, heightMm: number): string { const standard = STANDARD_PAGE_SIZES.find( (size) => Math.abs(size.widthMm - widthMm) < STANDARD_PAGE_TOLERANCE_MM && Math.abs(size.heightMm - heightMm) < STANDARD_PAGE_TOLERANCE_MM ); return standard?.name ?? `${widthMm}×${heightMm}mm`; } export interface CustomSheetInput { name: string; pageWidthMm: number; pageHeightMm: number; shape: LabelShape; cornerRadiusMm: number; columns: number; rows: number; labelWidthMm: number; labelHeightMm: number; marginLeftMm: number; marginTopMm: number; pitchXMm: number; pitchYMm: number; } export function makeCustomSheet(input: CustomSheetInput): SheetSpec { return { id: `${CUSTOM_SHEET_PREFIX}${crypto.randomUUID()}`, brand: 'Custom', code: input.name, pageWidthMm: input.pageWidthMm, pageHeightMm: input.pageHeightMm, pageName: derivePageName(input.pageWidthMm, input.pageHeightMm), shape: input.shape, cornerRadiusMm: input.cornerRadiusMm, columns: input.columns, rows: input.rows, labelWidthMm: input.labelWidthMm, labelHeightMm: input.labelHeightMm, marginLeftMm: input.marginLeftMm, marginTopMm: input.marginTopMm, pitchXMm: input.pitchXMm, pitchYMm: input.pitchYMm }; } const FIT_TOLERANCE_MM = 0.1; /** Checks that a sheet's label grid actually fits on its page; `null` means it fits. */ export function validateCustomSheet(sheet: SheetSpec): string | null { // Measured as the grid's own span, not its right/bottom edge on the page. A // caller that centres an oversized grid gives it a negative margin, which // would make an edge measurement understate how much room is actually needed. const gridWidthMm = (sheet.columns - 1) * sheet.pitchXMm + sheet.labelWidthMm; const gridHeightMm = (sheet.rows - 1) * sheet.pitchYMm + sheet.labelHeightMm; if (gridWidthMm > sheet.pageWidthMm + FIT_TOLERANCE_MM) { return `Labels overflow the page horizontally: ${sheet.columns} columns need ${gridWidthMm.toFixed(1)}mm but the page is only ${sheet.pageWidthMm}mm wide.`; } if (gridHeightMm > sheet.pageHeightMm + FIT_TOLERANCE_MM) { return `Labels overflow the page vertically: ${sheet.rows} rows need ${gridHeightMm.toFixed(1)}mm but the page is only ${sheet.pageHeightMm}mm tall.`; } if ( sheet.marginLeftMm < -FIT_TOLERANCE_MM || sheet.marginLeftMm + gridWidthMm > sheet.pageWidthMm + FIT_TOLERANCE_MM ) { return `The labels fit the page but sit off its left or right edge — check the horizontal margin.`; } if ( sheet.marginTopMm < -FIT_TOLERANCE_MM || sheet.marginTopMm + gridHeightMm > sheet.pageHeightMm + FIT_TOLERANCE_MM ) { return `The labels fit the page but sit off its top or bottom edge — check the vertical margin.`; } return null; }