// CUPS media (label size) helpers. // // The label printers are CUPS queues on the same host as this app (PM2). Each // queue's PPD carries a default PageSize (e.g. `Custom.3.5x1.7cm` for the // 35×17 mm product labels on labelprinter-2, `w288h432` = 4×6 in on the // shipping printers). `getQueueMediaMm()` reads that default via // `lpoptions -p -l` so PDFs can be rendered at the real label size // instead of relying on `-o fit-to-page` to shrink an oversized page. // // Fail-soft by design: on a dev Mac without CUPS queues, or when CUPS is // unreachable, every call resolves to `null` (cached) and callers fall back to // their legacy page formats. import { promisify } from 'node:util' import child_process from 'node:child_process' import { readFile } from 'node:fs/promises' export interface MediaMm { w: number; h: number } const PT_TO_MM = 25.4 / 72 const SAFE_QUEUE = /^[A-Za-z0-9_.-]+$/ const CACHE_TTL = 10 * 60 * 1000 const NAMED_SIZES: Record = { a3: { w: 297, h: 420 }, a4: { w: 210, h: 297 }, a5: { w: 148, h: 210 }, a6: { w: 105, h: 148 }, a7: { w: 74, h: 105 }, a8: { w: 52, h: 74 }, letter: { w: 215.9, h: 279.4 }, legal: { w: 215.9, h: 355.6 } } const round1 = (n: number) => Math.round(n * 10) / 10 /** * Parses a CUPS/PPD PageSize token into millimetres. * Supported: `Custom.3.5x1.7cm`, `Custom.35x17mm`, `Custom.4x6in`, * `Custom.99x48` (points), `w288h432` (points), named sizes (A4…A8, Letter). * Returns null for anything else. */ export const parsePageSizeToken = (token: string): MediaMm | null => { const t = String(token || '').trim() if (!t) return null let m = /^Custom\.(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)(mm|cm|in|pt|m)?$/i.exec(t) if (m) { const a = Number(m[1]); const b = Number(m[2]) const unit = (m[3] || 'pt').toLowerCase() const f = unit === 'mm' ? 1 : unit === 'cm' ? 10 : unit === 'in' ? 25.4 : unit === 'm' ? 1000 : PT_TO_MM const w = round1(a * f); const h = round1(b * f) return (w > 0 && h > 0) ? { w, h } : null } m = /^w(\d+(?:\.\d+)?)h(\d+(?:\.\d+)?)$/i.exec(t) if (m) { const w = round1(Number(m[1]) * PT_TO_MM); const h = round1(Number(m[2]) * PT_TO_MM) return (w > 0 && h > 0) ? { w, h } : null } const named = NAMED_SIZES[t.toLowerCase()] return named ? { ...named } : null } /** Extracts the starred (current) PageSize token from `lpoptions -l` output. */ export const parseLpoptionsPageSize = (output: string): MediaMm | null => { const lines = String(output || '').split(/\r?\n/) const line = lines.find(l => /^PageSize\//i.test(l)) || lines.find(l => /^media\//i.test(l)) if (!line) return null const starred = /\*(\S+)/.exec(line.split(':').slice(1).join(':')) return starred ? parsePageSizeToken(starred[1]) : null } /** `PageSize=`/`media=` from a queue's `lpoptions -p ` key=value output. */ export const parseLpoptionsQueueMedia = (output: string): MediaMm | null => { const m = /(?:^|\s)(?:media|PageSize)=(\S+)/.exec(String(output || '')) return m ? parsePageSizeToken(m[1]) : null } /** `*DefaultPageSize:` from a PPD file. */ export const parsePpdDefaultPageSize = (ppd: string): MediaMm | null => { const m = /^\*DefaultPageSize:\s*(\S+)/m.exec(String(ppd || '')) return m ? parsePageSizeToken(m[1]) : null } const cache = new Map() const warned = new Set() const PPD_DIR = process.env.CUPS_PPD_DIR || '/etc/cups/ppd' /** * Configured media of a CUPS queue in mm, cached 10 min per queue: * 1. queue-level option (`lpoptions -p ` → `media=`/`PageSize=`) * 2. the queue's PPD `*DefaultPageSize:` (/etc/cups/ppd/.ppd — * `lpoptions -l` only shows the generic `*Custom.WIDTHxHEIGHT` for custom * label sizes, the real dims live in the PPD) * 3. starred token of `lpoptions -p -l` * Null when the queue is unknown, CUPS is unavailable or nothing parses. */ export const getQueueMediaMm = async (queue: string): Promise => { const q = String(queue || '').trim() if (!q || !SAFE_QUEUE.test(q)) return null const hit = cache.get(q) if (hit && Date.now() - hit.ts < CACHE_TTL) return hit.value let value: MediaMm | null = null const exec = promisify(child_process.exec) const warn = (msg: string) => { if (warned.has(q)) return warned.add(q) console.warn(`[cupsMedia] ${q}: ${msg}`) } try { const { stdout } = await exec(`lpoptions -p ${q}`, { timeout: 3000 }) value = parseLpoptionsQueueMedia(stdout) } catch (err: any) { warn(`lpoptions failed: ${err?.message || err}`) } if (!value) { try { const ppd = await readFile(`${PPD_DIR}/${q}.ppd`, 'utf8') value = parsePpdDefaultPageSize(ppd) } catch (err: any) { warn(`PPD not readable: ${err?.message || err}`) } } if (!value) { try { const { stdout } = await exec(`lpoptions -p ${q} -l`, { timeout: 3000 }) value = parseLpoptionsPageSize(stdout) } catch { /* already warned above */ } } cache.set(q, { value, ts: Date.now() }) return value } export const clearCupsMediaCache = () => { cache.clear() } /** `-o media=Custom.xmm` — explicit page size for `lp`. */ export const mediaOption = (m: MediaMm): string => `-o media=Custom.${round1(m.w)}x${round1(m.h)}mm`