// PDF page-size detection + paper classification for the CUPS print routes. // // Why: the Jasper "Shipment A5" packing slip (420 × 595 pt) used to be treated as a // shipping label by the smart print routes (anything narrower than 500 pt) and was // sent to the Zebra label printer with fit-to-page. An A5 page is paper, not a label: // it belongs on the A4/paper queue with an explicit `-o media=A5`, so CUPS tells the // printer to pull A5 from the tray instead of scaling onto the queue's default size. // // Used by: print/attachment-smart, print/order-attachments, print/picklist, print/index. import { promisify } from 'node:util' import child_process from 'node:child_process' const exec = promisify(child_process.exec) export type PdfPageKind = 'label' | 'a5' | 'a4' | 'other' export interface PdfPageInfo { width: number height: number /** false when neither pdfinfo nor ghostscript could read the page box (A4 assumed) */ detected: boolean kind: PdfPageKind /** `-o media=…` for lp, or '' when the queue default should be used */ mediaOption: string } const near = (value: number, target: number, tolerance = 8) => Math.abs(value - target) <= tolerance /** Classifies a page box (points) — orientation-agnostic. */ export const classifyPdfPage = (width: number, height: number): PdfPageKind => { if (!width || !height) return 'other' const short = Math.min(width, height) const long = Math.max(width, height) if (near(short, 420, 8) && near(long, 595, 8)) return 'a5' if (near(short, 595, 8) && near(long, 842, 8)) return 'a4' // Letter / Legal and other paper sizes: printed as-is on the paper queue. if (short >= 500) return 'other' return 'label' } /** Explicit CUPS media only for the sizes the paper queues know by name. */ export const mediaOptionForKind = (kind: PdfPageKind): string => { if (kind === 'a5') return '-o media=A5' if (kind === 'a4') return '-o media=A4' return '' } /** * Reads the first page's size with `pdfinfo`, falling back to ghostscript. * Never throws — an unreadable file is reported as A4 (`detected: false`). */ export const detectPdfPage = async (filePath: string): Promise => { let width = 0 let height = 0 let detected = false try { const { stdout } = await exec(`pdfinfo "${filePath}" 2>/dev/null | grep -i "Page size"`) const match = stdout.match(/Page size:\s+([\d.]+)\s*x\s*([\d.]+)/) if (match) { width = parseFloat(match[1]) height = parseFloat(match[2]) detected = true } } catch { // pdfinfo missing or file unreadable — try ghostscript below } if (!detected) { try { const { stdout } = await exec(`gs -q -dNODISPLAY -dBATCH -dNOPAUSE -c "(${filePath}) (r) file runpdfbegin 1 pdfgetpage /MediaBox get == quit" 2>/dev/null`) const match = stdout.match(/\[\s*[\d.]+\s+[\d.]+\s+([\d.]+)\s+([\d.]+)\s*\]/) if (match) { width = parseFloat(match[1]) height = parseFloat(match[2]) detected = true } } catch { // fall through to the A4 default } } if (!detected) { width = 595 height = 842 } const kind = classifyPdfPage(width, height) return { width, height, detected, kind, mediaOption: mediaOptionForKind(kind) } } /** Appends the media option unless the caller already set one. */ export const withMediaOption = (options: string, page: PdfPageInfo): string => { const base = (options || '').trim() if (!page.mediaOption || /media=/.test(base)) return base return `${base} ${page.mediaOption}`.trim() }