/** * Label Designer — compile a `LabelTemplate` to a PDF at exact label size. * * Uses jsPDF (unit:'mm', format:[wMm,hMm]) so the design's millimetre * coordinates map straight onto the page. The output PDF is either sent to the * server print pipeline (`/api/print/labelprint`) or downloaded. Client-only: * jsPDF is dynamically imported behind an `import.meta.client` guard. * * Code elements (barcode/qr/datamatrix) are rasterised via `useBarcode` — the * same raster the on-screen SVG shows — and embedded with `addImage`. * * NOTE: v1 renders every element axis-aligned. The model carries a `rotation` * field (default 0, no UI control yet) reserved for a later enhancement; since * nothing sets it, screen and print are guaranteed identical. */ import type { LabelTemplate, LdElement, LdTextElement, LdImageElement, LdRectElement, LdEllipseElement, LdLineElement } from './useLabelDesign' import type { LdCodeElement } from './useBarcode' const MM_PER_PT = 25.4 / 72 interface RGB { r: number; g: number; b: number } const hexToRgb = (hex: string): RGB => { let h = (hex || '#000000').replace('#', '').trim() if (h.length === 3) h = h.split('').map(c => c + c).join('') const n = parseInt(h.slice(0, 6) || '000000', 16) return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 } } const isTransparent = (c?: string) => !c || c === 'transparent' || c === 'none' const imageFormatOf = (dataUrl: string): 'PNG' | 'JPEG' => { const m = /^data:image\/(png|jpe?g|webp)/i.exec(dataUrl || '') return m && /jpe?g/i.test(m[1]) ? 'JPEG' : 'PNG' } /** Natural pixel size of a data URL (client only). */ const imageSize = (dataUrl: string): Promise<{ w: number; h: number }> => new Promise(resolve => { if (!import.meta.client || !dataUrl) return resolve({ w: 1, h: 1 }) const img = new Image() img.onload = () => resolve({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 }) img.onerror = () => resolve({ w: 1, h: 1 }) img.src = dataUrl }) /** Fit (w×h source aspect) into a box, returning the centred draw rect. */ const fitRect = ( bx: number, by: number, bw: number, bh: number, sw: number, sh: number, mode: 'fill' | 'contain' | 'cover' ) => { if (mode === 'fill' || sw <= 0 || sh <= 0) return { x: bx, y: by, w: bw, h: bh } const sr = sw / sh const br = bw / bh let dw = bw; let dh = bh if (mode === 'contain' ? sr > br : sr < br) { dw = bw; dh = bw / sr } else { dh = bh; dw = bh * sr } return { x: bx + (bw - dw) / 2, y: by + (bh - dh) / 2, w: dw, h: dh } } export function usePdfCompiler () { const { render: renderCode, isSquareCode } = useBarcode() const drawText = (doc: any, el: LdTextElement) => { const { r, g, b } = hexToRgb(el.color) doc.setTextColor(r, g, b) const style = el.fontStyle || 'normal' doc.setFont(el.fontFamily || 'helvetica', style) doc.setFontSize(el.fontSizePt || 11) const lineHeight = el.lineHeight || 1.15 doc.setLineHeightFactor(lineHeight) if (el.letterSpacingPt) doc.setCharSpace(el.letterSpacingPt * MM_PER_PT) const raw = String(el.text ?? '') let lines: string[] if (el.wrap) { lines = raw.split('\n').flatMap((para: string) => doc.splitTextToSize(para, Math.max(1, el.w))) } else { lines = raw.split('\n') } const lineMm = (el.fontSizePt || 11) * lineHeight * MM_PER_PT const blockH = lines.length * lineMm let yTop = el.y if (el.vAlign === 'middle') yTop = el.y + (el.h - blockH) / 2 else if (el.vAlign === 'bottom') yTop = el.y + (el.h - blockH) let ax = el.x if (el.align === 'center') ax = el.x + el.w / 2 else if (el.align === 'right') ax = el.x + el.w doc.text(lines, ax, yTop, { baseline: 'top', align: el.align || 'left' }) if (el.letterSpacingPt) doc.setCharSpace(0) } const drawRect = (doc: any, el: LdRectElement) => { const hasFill = !isTransparent(el.fill) const hasStroke = !isTransparent(el.stroke) && el.strokeWidthMm > 0 if (!hasFill && !hasStroke) return if (hasFill) { const c = hexToRgb(el.fill); doc.setFillColor(c.r, c.g, c.b) } if (hasStroke) { const c = hexToRgb(el.stroke); doc.setDrawColor(c.r, c.g, c.b); doc.setLineWidth(el.strokeWidthMm) } const style = hasFill && hasStroke ? 'FD' : hasFill ? 'F' : 'S' if (el.radiusMm > 0) { const rr = Math.min(el.radiusMm, el.w / 2, el.h / 2) doc.roundedRect(el.x, el.y, el.w, el.h, rr, rr, style) } else { doc.rect(el.x, el.y, el.w, el.h, style) } } const drawEllipse = (doc: any, el: LdEllipseElement) => { const hasFill = !isTransparent(el.fill) const hasStroke = !isTransparent(el.stroke) && el.strokeWidthMm > 0 if (!hasFill && !hasStroke) return if (hasFill) { const c = hexToRgb(el.fill); doc.setFillColor(c.r, c.g, c.b) } if (hasStroke) { const c = hexToRgb(el.stroke); doc.setDrawColor(c.r, c.g, c.b); doc.setLineWidth(el.strokeWidthMm) } const style = hasFill && hasStroke ? 'FD' : hasFill ? 'F' : 'S' doc.ellipse(el.x + el.w / 2, el.y + el.h / 2, el.w / 2, el.h / 2, style) } const drawLine = (doc: any, el: LdLineElement) => { if (isTransparent(el.stroke) || el.strokeWidthMm <= 0) return const c = hexToRgb(el.stroke) doc.setDrawColor(c.r, c.g, c.b) doc.setLineWidth(el.strokeWidthMm) if (Array.isArray(el.dash) && el.dash.length) doc.setLineDashPattern(el.dash, 0) doc.line(el.x, el.y, el.x + el.w, el.y + el.h) if (Array.isArray(el.dash) && el.dash.length) doc.setLineDashPattern([], 0) } const drawImage = async (doc: any, el: LdImageElement) => { if (!el.dataUrl) return const fmt = imageFormatOf(el.dataUrl) const { w: sw, h: sh } = await imageSize(el.dataUrl) const rect = fitRect(el.x, el.y, el.w, el.h, sw, sh, el.fit || 'contain') const opacity = el.opacity ?? 1 const useG = opacity < 1 && typeof doc.GState === 'function' if (useG) { doc.saveGraphicsState(); doc.setGState(new doc.GState({ opacity })) } try { doc.addImage(el.dataUrl, fmt, rect.x, rect.y, rect.w, rect.h, undefined, 'FAST') } catch (err) { console.warn('[label-designer] addImage failed:', err) } if (useG) doc.restoreGraphicsState() } const drawCode = async (doc: any, el: LdCodeElement, dpi: number) => { const raster = await renderCode(el, { dpi }) if (!raster) { // invalid data — mark the spot with a faint outline so it's noticed. doc.setDrawColor(200, 60, 60); doc.setLineWidth(0.2) doc.rect(el.x, el.y, el.w, el.h, 'S') return } let { x, y, w, h } = { x: el.x, y: el.y, w: el.w, h: el.h } if (isSquareCode(el)) { const side = Math.min(el.w, el.h) x = el.x + (el.w - side) / 2 y = el.y + (el.h - side) / 2 w = side; h = side } try { doc.addImage(raster.dataUrl, 'PNG', x, y, w, h, undefined, 'FAST') } catch (err) { console.warn('[label-designer] code addImage failed:', err) } } const drawElement = async (doc: any, el: LdElement, dpi: number) => { if (el.visible === false) return switch (el.type) { case 'text': return drawText(doc, el as LdTextElement) case 'rect': return drawRect(doc, el as LdRectElement) case 'ellipse': return drawEllipse(doc, el as LdEllipseElement) case 'line': return drawLine(doc, el as LdLineElement) case 'image': return drawImage(doc, el as LdImageElement) case 'barcode': case 'qr': case 'datamatrix': return drawCode(doc, el as LdCodeElement, dpi) } } const paintBackground = (doc: any, tpl: LabelTemplate) => { const bg = (tpl.background || '#FFFFFF').toLowerCase() if (bg === '#ffffff' || bg === '#fff' || isTransparent(bg)) return const c = hexToRgb(bg) doc.setFillColor(c.r, c.g, c.b) doc.rect(0, 0, tpl.size.wMm, tpl.size.hMm, 'F') } /** * Build the jsPDF document. `copies` appends N identical pages. * Returns the jsPDF instance (caller decides save / output). */ async function compileToPdf (tpl: LabelTemplate, opts: { copies?: number; dpi?: number } = {}): Promise { if (!import.meta.client) throw new Error('PDF generation is client-only') const copies = Math.max(1, Math.min(200, Math.floor(opts.copies ?? 1))) const dpi = opts.dpi ?? 300 const wMm = tpl.size.wMm const hMm = tpl.size.hMm const orientation = wMm >= hMm ? 'landscape' : 'portrait' const { jsPDF } = await import('jspdf') const doc = new jsPDF({ orientation, unit: 'mm', format: [wMm, hMm], compress: true, putOnlyUsedFonts: true }) for (let copy = 0; copy < copies; copy++) { if (copy > 0) doc.addPage([wMm, hMm], orientation) paintBackground(doc, tpl) for (const el of tpl.elements) { // sequential: keeps draw order == z-order and avoids interleaved jsPDF state await drawElement(doc, el, dpi) } } return doc } /** Base64 (no data: prefix) ready for POST /api/print/labelprint. */ function toBase64 (doc: any): string { return String(doc.output('dataurlstring') || '') .replace(/^data:application\/pdf;[^,]*base64,/, '') } return { compileToPdf, toBase64 } }