import type { RawImageDataSource, SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' import { resolveMediaUrlForApp, storedValueLooksLikeImagePath } from '../resolveMediaUrl' import { sortElementsForPreview } from './normalizePreviewTemplate' import QRCode from 'qrcode' const NUTRITION_FIXED_ITEMS = [ { key: 'fat', label: 'Total Fat' }, { key: 'saturatedFat', label: 'Saturated Fat' }, { key: 'transFat', label: 'Trans Fat' }, { key: 'cholesterol', label: 'Cholesterol' }, { key: 'sodium', label: 'Sodium' }, { key: 'carbs', label: 'Total Carbohydrates' }, { key: 'dietaryFiber', label: 'Dietary Fiber' }, { key: 'totalSugar', label: 'Total Sugar' }, { key: 'protein', label: 'Protein' }, { key: 'vitaminA', label: 'Vitamin A' }, { key: 'vitaminC', label: 'Vitamin C' }, { key: 'calcium', label: 'Calcium' }, { key: 'iron', label: 'Iron' }, ] /** 与 Web LabelCanvas.unitToPx 一致:cm 用 37.8px/inch,保证与后台模板坐标系一致 */ const PX_PER_CM = 37.8 const PX_PER_INCH = 96 function toCanvasPx(value: number, unit: string): number { const u = String(unit || 'inch').toLowerCase() if (u === 'mm') return (value / 25.4) * PX_PER_INCH if (u === 'cm') return value * PX_PER_CM if (u === 'px') return value return value * PX_PER_INCH } function cfgStr(config: Record, keys: string[], fallback = ''): string { for (const k of keys) { const v = config?.[k] if (v != null && v !== '') return String(v) } return fallback } /** 前缀在正文前;若正文已以前缀开头则不再重复拼接 */ function applyConfigPrefix(config: Record, body: string): string { const prefix = String(config.prefix ?? config.Prefix ?? '') if (!prefix) return body const b = body ?? '' if (b.startsWith(prefix)) return b return `${prefix}${b}` } function readFontSize(config: Record): number { const n = Number(config.fontSize ?? config.FontSize ?? 14) return Math.max(6, Math.round(Number.isFinite(n) ? n : 14)) } function readTextAlign(config: Record): string { return String(config.textAlign ?? config.TextAlign ?? 'left').toLowerCase() } function readFillColor(config: Record): string { return String(config.color ?? config.Color ?? '#111827') } /** 按元素框宽度估算每行最大字符数(等宽近似,兼容中英文) */ function maxCharsPerLine(innerWidthPx: number, fontSize: number): number { if (innerWidthPx <= 4) return 8 const approx = Math.max(0.45, Math.min(0.75, 0.55)) /** 下限 8:避免过窄估算时按 4 字硬切把英文单词拦腰截断(如 All|ergens) */ return Math.max(8, Math.floor(innerWidthPx / (fontSize * approx))) } /** * 优先在空格处断行,长词再按字符切分;避免固定宽度硬切破坏英文单词与「标签: 值」可读性。 */ function wrapSingleLogicalLine(line: string, maxChars: number): string[] { const limit = Math.max(8, maxChars) const s = String(line) if (s.length <= limit) return [s] const words = s.split(/(\s+)/) const out: string[] = [] let cur = '' const pushLongToken = (token: string) => { for (let i = 0; i < token.length; i += limit) { out.push(token.slice(i, i + limit)) } } for (const w of words) { if (/^\s+$/.test(w)) { cur += w continue } if (!w) continue const trimmedRight = cur.replace(/\s+$/, '') const candidate = trimmedRight ? `${trimmedRight} ${w}` : w if (candidate.length <= limit) { cur = candidate } else { if (trimmedRight) out.push(trimmedRight) cur = '' if (w.length > limit) { pushLongToken(w) } else { cur = w } } } const tail = cur.replace(/\s+$/, '') if (tail) out.push(tail) return out.length ? out : [''] } function wrapTextToWidth(text: string, maxChars: number): string[] { const lines = String(text).split(/\r?\n/) const out: string[] = [] for (const line of lines) { out.push(...wrapSingleLogicalLine(line, maxChars)) } return out.length ? out : [''] } function previewTextForElement(element: SystemTemplateElementBase): string { const type = String(element.type || '').toUpperCase() const config = element.config || {} if (type === 'QRCODE') { return cfgStr(config, ['data', 'Data', 'value', 'Value']) } if (type === 'BARCODE') { // 平台模板条码值常见键:data / barcodeData(含大小写变体) return cfgStr(config, ['data', 'Data', 'barcodeData', 'BarcodeData', 'value', 'Value']) } const vst = String(element.valueSourceType || '').toUpperCase() const inputType = String(config.inputType ?? config.InputType ?? '').toLowerCase() const hasDict = !!(config.multipleOptionId ?? config.MultipleOptionId) if (vst === 'PRINT_INPUT' && (inputType === 'options' || hasDict)) { const rawSel = config.selectedOptionValues ?? config.SelectedOptionValues const arr = Array.isArray(rawSel) ? rawSel.map((x: unknown) => String(x)) : [] const txt = cfgStr(config, ['text', 'Text'], '') if (arr.length > 0 && txt.trim()) return txt if (arr.length > 0) { const joined = arr.join(', ') return applyConfigPrefix(config, joined) } const hint = txt.trim() || 'Select below' return applyConfigPrefix(config, hint) } if (vst === 'PRINT_INPUT' && !(inputType === 'options' || hasDict)) { let body = cfgStr(config, ['text', 'Text'], '') if (!body.trim()) body = cfgStr(config, ['value', 'Value'], '') if (!body.trim()) body = cfgStr(config, ['format', 'Format', 'placeholder', 'Placeholder'], '') const unit = String(config.unit ?? config.Unit ?? '').trim() if (unit && body.trim() && !body.endsWith(unit)) body = `${body}${unit}` return applyConfigPrefix(config, body) } const body = cfgStr(config, [ 'text', 'Text', 'format', 'Format', 'content', 'Content', 'value', 'Value', 'displayText', 'displayValue', 'defaultValue', 'placeholder', ]) return applyConfigPrefix(config, body) } function isGraphicOnlyType(type: string): boolean { return type === 'IMAGE' || type === 'LOGO' || type === 'BARCODE' || type === 'QRCODE' } function previewExportPixelRatio(): number { try { const pr = uni.getSystemInfoSync().pixelRatio return Math.min(2.5, Math.max(1, typeof pr === 'number' && pr > 0 ? pr : 2)) } catch { return 2 } } function barcodeModulesFromValue(value: string): number[] { const s = String(value || '').trim() if (!s) return [] const modules: number[] = [] // quiet + start(轻量预览编码:保证视觉稳定,非扫描级编码) modules.push(1, 0, 1, 0, 1, 0, 1, 0) for (let i = 0; i < s.length; i++) { const code = s.charCodeAt(i) & 0xff // 用 5bit 模块 + 分隔位,避免在窄宽度里“整块发黑”。 const key = (code ^ (i * 13) ^ (s.length * 7)) & 0x1f for (let b = 4; b >= 0; b--) modules.push((key >> b) & 1) modules.push(0) } // stop modules.push(1, 0, 1, 1, 0, 1, 0, 1) return modules } function drawBarcodeLikePreview( ctx: UniApp.CanvasContext, x: number, y: number, w: number, h: number, value: string, options?: { orientation?: string; showText?: boolean } ): void { const bw = Math.max(40, w || 140) const bh = Math.max(28, h || 56) const showText = options?.showText !== false const pad = 2 const modules = barcodeModulesFromValue(value) const orientation = String(options?.orientation || 'horizontal').toLowerCase() const isVertical = orientation === 'vertical' ctx.setFillStyle('#ffffff') ctx.fillRect(x, y, bw, bh) if (!modules.length) return const txt = String(value || '').trim() ctx.setFillStyle('#111827') if (!isVertical) { const textH = showText && txt ? Math.max(10, Math.round(bh * 0.2)) : 0 const barH = Math.max(10, bh - textH - pad * 2) const innerW = Math.max(8, bw - pad * 2) const moduleW = innerW / modules.length let cursor = x + pad for (let i = 0; i < modules.length; i++) { if (modules[i] === 1) { const rw = Math.max(0.7, moduleW * 0.86) ctx.fillRect(cursor, y + pad, rw, barH) } cursor += moduleW } if (showText && txt) { ctx.setFontSize(Math.max(9, Math.min(12, Math.round(textH * 0.8)))) ctx.setTextAlign('center') ctx.fillText(txt, x + bw / 2, y + bh - 2) ctx.setTextAlign('left') } return } // vertical:条码在左,data 竖排在右 const textBandW = showText && txt ? Math.max(10, Math.round(bw * 0.18)) : 0 const barW = Math.max(10, bw - textBandW - pad * 2) const innerH = Math.max(10, bh - pad * 2) const moduleH = innerH / modules.length let cursorY = y + pad for (let i = 0; i < modules.length; i++) { if (modules[i] === 1) { const rh = Math.max(0.7, moduleH * 0.86) ctx.fillRect(x + pad, cursorY, barW, rh) } cursorY += moduleH } if (showText && txt) { const font = Math.max(9, Math.min(11, Math.floor(textBandW * 0.75))) const cx = x + bw - textBandW / 2 const cy = y + bh / 2 const anyCtx = ctx as any if (typeof anyCtx.save === 'function' && typeof anyCtx.rotate === 'function') { anyCtx.save() anyCtx.translate(cx, cy) // 竖排文本按模板端习惯:从下到上 anyCtx.rotate(-Math.PI / 2) ctx.setFontSize(font) ctx.setTextAlign('center') ctx.fillText(txt, 0, Math.min(font * 0.35, 4)) ctx.setTextAlign('left') anyCtx.restore() } else { // 低端环境兜底:不旋转能力时退化为逐字竖排 let ty = y + pad + font ctx.setFontSize(font) ctx.setTextAlign('center') for (let i = 0; i < txt.length; i++) { ctx.fillText(txt[i], cx, ty) ty += font + 1 if (ty > y + bh - 1) break } ctx.setTextAlign('left') } } } function drawQrCodePreview( ctx: UniApp.CanvasContext, x: number, y: number, w: number, h: number, value: string, errorLevelRaw?: string, ): void { const text = String(value || '').trim() const bw = Math.max(24, w || 96) const bh = Math.max(24, h || 96) if (!text) return let matrixSize = 0 let moduleData: Uint8Array | number[] = [] try { const lv = String(errorLevelRaw || 'M').trim().toUpperCase() const level = lv === 'L' || lv === 'M' || lv === 'Q' || lv === 'H' ? lv : 'M' const qr = QRCode.create(text, { errorCorrectionLevel: level }) matrixSize = Number(qr?.modules?.size || 0) moduleData = (qr?.modules?.data as Uint8Array | number[]) || [] } catch { matrixSize = 0 moduleData = [] } if (!matrixSize || !moduleData.length) return const pad = Math.max(1, Math.floor(Math.min(bw, bh) * 0.06)) const side = Math.max(12, Math.min(bw, bh) - pad * 2) const cell = Math.max(1, Math.floor(side / matrixSize)) const drawSide = cell * matrixSize const ox = x + Math.floor((bw - drawSide) / 2) const oy = y + Math.floor((bh - drawSide) / 2) ctx.setFillStyle('#ffffff') ctx.fillRect(x, y, bw, bh) ctx.setFillStyle('#111827') for (let r = 0; r < matrixSize; r++) { for (let c = 0; c < matrixSize; c++) { const idx = r * matrixSize + c const dark = Number((moduleData as any)[idx]) === 1 if (!dark) continue ctx.fillRect(ox + c * cell, oy + r * cell, cell, cell) } } } function nutritionFixedField(cfg: Record, key: string, field: 'value' | 'unit'): string { const directKey = field === 'value' ? key : `${key}Unit` const direct = cfg[directKey] if (direct != null && String(direct).trim() !== '') return String(direct).trim() const rows = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Array>) : [] const row = rows.find((item) => String(item.key ?? '').trim() === key) return String(row?.[field] ?? '').trim() } function nutritionExtraRows(cfg: Record): Array<{ name: string; value: string; unit: string }> { const raw = cfg.extraNutrients if (!Array.isArray(raw)) return [] return raw.map((item) => { const row = (item || {}) as Record return { name: String(row.name ?? '').trim(), value: String(row.value ?? '').trim(), unit: String(row.unit ?? '').trim(), } }) } function nutritionValueWithLessThan(value: string, unit: string): string { const v = String(value || '').trim() const u = String(unit || '').trim() if (!v && !u) return '' return `<${v}${u ? ` ${u}` : ''}` } /** 与屏幕预览 / 位图打印共用绘制逻辑(坐标系:设计宽 cw × ch,ctx 已 scale) */ function runLabelPreviewCanvasDraw( canvasId: string, componentInstance: any, template: SystemLabelTemplate, cw: number, ch: number, scale: number ): Promise { const sorted = sortElementsForPreview(template.elements || []) return new Promise((resolve) => { const ctx = uni.createCanvasContext(canvasId, componentInstance) ctx.setFillStyle('#ffffff') ctx.scale(scale, scale) ctx.fillRect(0, 0, cw, ch) const drawRest = (index: number) => { if (index >= sorted.length) { ctx.draw(false, () => resolve()) return } const el = sorted[index] const type = String(el.type || '').toUpperCase() const config = el.config || {} const x = Number(el.x) || 0 const y = Number(el.y) || 0 const w = Math.max(0, Number(el.width) || 0) const h = Math.max(0, Number(el.height) || 0) const next = () => drawRest(index + 1) if (type === 'IMAGE' || type === 'LOGO') { const src = resolveMediaUrlForApp(cfgStr(config, ['src', 'url', 'Src', 'Url'])) if (src) { uni.getImageInfo({ src, success: (info) => { try { ctx.drawImage(info.path, x, y, w || info.width, h || info.height) } catch (_) { ctx.setStrokeStyle('#cccccc') ctx.setLineWidth(1) ctx.strokeRect(x, y, w || 80, h || 40) } next() }, fail: () => { ctx.setStrokeStyle('#cccccc') ctx.strokeRect(x, y, w || 80, h || 40) next() }, }) return } next() return } if (type === 'NUTRITION') { const bw = Math.max(40, w || 120) const bh = Math.max(36, h || 96) const pad = 3 const rightX = x + bw - pad const maxY = y + bh - 2 const titleSize = Math.max(11, Math.min(18, Number(config.nutritionTitleFontSize ?? config.NutritionTitleFontSize ?? 16) || 16)) const bodySize = Math.max(8, Math.min(11, Math.floor(titleSize * 0.72))) let cursorY = y + pad const servingsPerContainer = String(config.servingsPerContainer ?? config.ServingsPerContainer ?? '').trim() const servingSize = String(config.servingSize ?? config.ServingSize ?? '').trim() const calories = String(config.calories ?? config.Calories ?? nutritionFixedField(config, 'calories', 'value') ?? '').trim() const rows = [ ...NUTRITION_FIXED_ITEMS.map((item) => { const value = nutritionFixedField(config, item.key, 'value') const unit = nutritionFixedField(config, item.key, 'unit') if (!value) return null return { label: item.label, value: nutritionValueWithLessThan(value, unit) } }).filter(Boolean) as Array<{ label: string; value: string }>, ...nutritionExtraRows(config) .filter((r) => r.value) .map((r) => ({ label: r.name || 'Other', value: nutritionValueWithLessThan(r.value, r.unit), })), ] const drawPair = (label: string, value: string, fs: number, bold = false): boolean => { const f = Math.max(8, Math.round(fs)) const lh = f + 2 if (cursorY + lh > maxY) return false ctx.setFillStyle('#111827') ctx.setFontSize(f) ctx.setTextAlign('left') ctx.fillText(label, x + pad, cursorY + f) if (bold) ctx.fillText(label, x + pad + 0.5, cursorY + f) if (value) { ctx.setTextAlign('right') ctx.fillText(value, rightX, cursorY + f) if (bold) ctx.fillText(value, rightX + 0.5, cursorY + f) ctx.setTextAlign('left') } cursorY += lh return true } ctx.setFillStyle('#ffffff') ctx.fillRect(x, y, bw, bh) ctx.setStrokeStyle('#111827') ctx.setLineWidth(1) ctx.strokeRect(x, y, bw, bh) if (cursorY + titleSize + 2 <= maxY) { ctx.setFillStyle('#111827') ctx.setFontSize(Math.round(titleSize)) ctx.setTextAlign('left') ctx.fillText('Nutrition Facts', x + pad, cursorY + Math.round(titleSize)) cursorY += Math.round(titleSize) + 2 ctx.setLineWidth(1) ctx.beginPath() ctx.moveTo(x + pad, cursorY) ctx.lineTo(rightX, cursorY) ctx.stroke() cursorY += 2 } if (calories) drawPair('Calories', nutritionValueWithLessThan(calories, ''), Math.max(bodySize, 9), true) if (servingsPerContainer) drawPair('Servings Per Container', servingsPerContainer, bodySize) if (servingSize) drawPair('Serving Size', servingSize, bodySize) for (const row of rows) { if (!drawPair(row.label, row.value, bodySize, true)) break } next() return } if (type === 'QRCODE' || type === 'BARCODE') { const d = previewTextForElement(el) const drawQrBarcodePlaceholder = () => { ctx.setFillStyle('#f3f4f6') ctx.fillRect(x, y, w || 60, h || 60) ctx.setStrokeStyle('#9ca3af') ctx.setLineWidth(1) ctx.strokeRect(x, y, w || 60, h || 60) ctx.setFillStyle('#374151') ctx.setFontSize(10) const label = type === 'QRCODE' ? 'QR' : 'BC' ctx.fillText(label, x + 4, y + 14) if (d) { const short = d.length > 12 ? `${d.slice(0, 10)}…` : d ctx.fillText(short, x + 4, y + 28) } } if (type === 'BARCODE' && d) { const orientation = cfgStr(config, ['orientation', 'Orientation'], 'horizontal') const showText = String(config.showText ?? config.ShowText ?? 'true').toLowerCase() !== 'false' drawBarcodeLikePreview(ctx, x, y, w || 140, h || 56, d, { orientation, showText }) next() return } if (type === 'QRCODE' && d && !storedValueLooksLikeImagePath(d)) { drawQrCodePreview(ctx, x, y, w || 96, h || 96, d, cfgStr(config, ['errorLevel', 'ErrorLevel'], 'M')) next() return } // 管理端可把二维码默认值存为上传图片路径,须按位图绘制而非占位符文本 if (type === 'QRCODE' && d && storedValueLooksLikeImagePath(d)) { const src = resolveMediaUrlForApp(d) if (src) { uni.getImageInfo({ src, success: (info) => { try { const dw = w || info.width const dh = h || info.height ctx.drawImage(info.path, x, y, dw, dh) } catch (_) { drawQrBarcodePlaceholder() } next() }, fail: () => { drawQrBarcodePlaceholder() next() }, }) return } } drawQrBarcodePlaceholder() next() return } const line = String(el.border || '').toLowerCase() if (line === 'line' || line === 'solid') { ctx.setStrokeStyle('#111827') ctx.setLineWidth(1) ctx.strokeRect(x, y, w, h) } else if (line === 'dotted') { ctx.setStrokeStyle('#9ca3af') ctx.setLineWidth(1) if (typeof (ctx as any).setLineDash === 'function') { ;(ctx as any).setLineDash([3, 3], 0) ctx.strokeRect(x, y, w, h) ;(ctx as any).setLineDash([], 0) } else { ctx.strokeRect(x, y, w, h) } } const text = previewTextForElement(el) if (text && !isGraphicOnlyType(type)) { const fontSize = readFontSize(config) const color = readFillColor(config) ctx.setFillStyle(color) ctx.setFontSize(fontSize) const fontWeight = String(config.fontWeight ?? config.FontWeight ?? 'normal').toLowerCase() if (typeof (ctx as any).setFontWeight === 'function') { ;(ctx as any).setFontWeight(fontWeight === 'bold' || fontWeight === '700' ? 'bold' : 'normal') } const align = readTextAlign(config) const pad = 2 const innerW = Math.max(0, w - pad * 2) const innerH = Math.max(fontSize, h - pad * 2) let tx = x + pad if (align === 'center') tx = x + w / 2 else if (align === 'right') tx = x + w - pad ctx.setTextAlign(align === 'center' ? 'center' : align === 'right' ? 'right' : 'left') const lineHeight = fontSize + Math.max(2, Math.round(fontSize * 0.15)) const maxChars = maxCharsPerLine(innerW, fontSize) const lines = wrapTextToWidth(text, maxChars) const maxLines = innerH >= fontSize ? Math.max(1, Math.floor(innerH / lineHeight)) : lines.length const startY = y + pad + fontSize lines.slice(0, maxLines).forEach((ln, li) => { ctx.fillText(ln, tx, startY + li * lineHeight) }) ctx.setTextAlign('left') } next() } drawRest(0) }) } /** * 将模板绘制到 canvas,并导出临时路径供 展示。 */ export function renderLabelPreviewToTempPath( canvasId: string, componentInstance: any, template: SystemLabelTemplate, maxDisplayWidthPx = 720 ): Promise { const unit = template.unit || 'inch' const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit))) const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) const scale = Math.min(1, maxDisplayWidthPx / cw) const outW = Math.max(1, Math.round(cw * scale)) const outH = Math.max(1, Math.round(ch * scale)) const exportPr = previewExportPixelRatio() return runLabelPreviewCanvasDraw(canvasId, componentInstance, template, cw, ch, scale).then( () => new Promise((resolve, reject) => { setTimeout(() => { uni.canvasToTempFilePath( { canvasId, width: outW, height: outH, destWidth: Math.round(outW * exportPr), destHeight: Math.round(outH * exportPr), success: (res) => resolve(res.tempFilePath), fail: (err) => reject(new Error(err.errMsg || 'canvasToTempFilePath failed')), }, componentInstance ) }, 120) }) ) } /** * 打印专用:与屏幕预览相同走 canvasToTempFilePath(已验证能出图),再由 printImageForCurrentPrinter 用原生 Bitmap 解码光栅化。 * 避免 canvasGetImageData 在部分机型/页面上下文中返回空或错位,导致 BLE 发出“空标签”仍回调成功。 */ export function renderLabelPreviewCanvasToTempPathForPrint( canvasId: string, componentInstance: any, template: SystemLabelTemplate, layout: { cw: number, ch: number, outW: number, outH: number, scale: number } ): Promise { const { cw, ch, outW, outH, scale } = layout return runLabelPreviewCanvasDraw(canvasId, componentInstance, template, cw, ch, scale).then( () => new Promise((resolve, reject) => { setTimeout(() => { uni.canvasToTempFilePath( { canvasId, x: 0, y: 0, width: outW, height: outH, destWidth: outW, destHeight: outH, fileType: 'png', quality: 1, success: (res) => resolve(res.tempFilePath), fail: (err) => reject(new Error(err.errMsg || 'canvasToTempFilePath for print failed')), }, componentInstance ) }, 150) }) ) } /** * 与 `shouldRasterPrintViaCanvasImageData()` 配套:内置 UPOS 等机型不走 Bitmap int[],与 Test Print 一致用 canvasGetImageData。 */ export function renderLabelPreviewCanvasImageDataForPrint( canvasId: string, componentInstance: any, template: SystemLabelTemplate, layout: { cw: number, ch: number, outW: number, outH: number, scale: number } ): Promise { const { cw, ch, outW, outH, scale } = layout return runLabelPreviewCanvasDraw(canvasId, componentInstance, template, cw, ch, scale).then( () => new Promise((resolve, reject) => { setTimeout(() => { uni.canvasGetImageData( { canvasId, x: 0, y: 0, width: outW, height: outH, success: (res: any) => { resolve({ width: outW, height: outH, data: res.data, }) }, fail: (err: any) => { reject(new Error(err?.errMsg || 'canvasGetImageData for print failed')) }, }, componentInstance ) }, 150) }) ) } /** * 按打印机最大宽度(dots)与 DPI 计算栅格尺寸;宽为 8 的倍数,与 Test Print / rasterizeImageData 一致。 */ export function getLabelPrintRasterLayout( template: SystemLabelTemplate, maxWidthDots: number, printDpi = 203 ): { cw: number, ch: number, outW: number, outH: number, scale: number } { const unit = template.unit || 'inch' const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit))) const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) const designDpi = 96 const idealW = Math.round(cw * (printDpi / designDpi)) const cap = Math.max(8, Math.round(maxWidthDots || 576)) let outW = Math.max(8, Math.min(cap, idealW)) outW -= outW % 8 if (outW < 8) outW = 8 const scale = outW / cw const outH = Math.max(1, Math.round(ch * scale)) return { cw, ch, outW, outH, scale } } export function getPreviewCanvasCssSize(template: SystemLabelTemplate, maxDisplayWidthPx = 720): { width: number height: number } { const unit = template.unit || 'inch' const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit))) const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) const scale = Math.min(1, maxDisplayWidthPx / cw) return { width: Math.max(1, Math.round(cw * scale)), height: Math.max(1, Math.round(ch * scale)), } }