import type { RawImageDataSource, SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' import { resolveMediaUrlForApp, storedValueLooksLikeImagePath } from '../resolveMediaUrl' import { sortElementsForPreview, normalizeTemplatePrintOrientation } from './normalizePreviewTemplate' import { resolveElementDateTimeDisplay, isLikelyResolvedDateTimeLiteral } from './printInputOffset' import { getLoggedInEmployeeDisplayName, isEmployeeTemplateElement } from './employeeElement' import QRCode from 'qrcode' import { readInvertColors } from '../invertColorsConfig' import { ensureLabelEditorFontsForTemplate, isLabelEditorFontItalicLoaded, resolveLabelEditorFontFamily, } from '../labelEditorFonts' import { computeVerticalTextBlockOffset, readVerticalAlign, readElementBorder, readFontStyle, readFontWeight, readTextDecoration, readElementRotation } from '../textElementLayout' import { computeImageDrawRect, readImageScaleMode } from '../imageScaleMode' import { buildNutritionFactsViewModel, DEFAULT_NUTRITION_FOOTER_NOTE, NUTRITION_AMOUNT_COL_WIDTH, NUTRITION_BODY_FONT_SIZE, NUTRITION_PCT_COL_WIDTH, type NutritionDivider, } from '../nutritionFactsLayout' /** 与 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 applyCanvasFontFromConfig( ctx: UniApp.CanvasContext, config: Record, fontSize: number, ): void { const fontFamily = resolveLabelEditorFontFamily(config) const anyCtx = ctx as any const weight = readFontWeight(config) === 'bold' ? 'bold' : 'normal' const style = readFontStyle(config) === 'italic' ? 'italic' : 'normal' if (typeof anyCtx.setFontFamily === 'function') { anyCtx.setFontFamily(fontFamily) } if (typeof anyCtx.setFontWeight === 'function') { anyCtx.setFontWeight(weight) } if (typeof anyCtx.font !== 'undefined') { anyCtx.font = `${style} ${weight} ${fontSize}px "${fontFamily}"` } } function approxTextWidth(text: string, fontSize: number): number { return Math.max(4, String(text).length * fontSize * 0.55) } function drawStyledTextLine( ctx: UniApp.CanvasContext, line: string, tx: number, y: number, fontSize: number, align: string, config: Record, fillColor: string, ): void { const family = resolveLabelEditorFontFamily(config) const italic = readFontStyle(config) === 'italic' const underline = readTextDecoration(config) === 'underline' const useSkewFallback = italic && !isLabelEditorFontItalicLoaded(family) const anyCtx = ctx as any const lineWidth = approxTextWidth(line, fontSize) if (useSkewFallback && typeof anyCtx.save === 'function') { anyCtx.save() anyCtx.transform(1, 0, -0.25, 1, tx * 0.08, 0) } ctx.fillText(line, tx, y) if (underline) { let x1 = tx let x2 = tx + lineWidth if (align === 'center') { x1 = tx - lineWidth / 2 x2 = tx + lineWidth / 2 } else if (align === 'right') { x1 = tx - lineWidth x2 = tx } ctx.setStrokeStyle(fillColor) ctx.setLineWidth(Math.max(1, Math.round(fontSize * 0.06))) ctx.beginPath() ctx.moveTo(x1, y + Math.max(1, Math.round(fontSize * 0.12))) ctx.lineTo(x2, y + Math.max(1, Math.round(fontSize * 0.12))) ctx.stroke() } if (useSkewFallback && typeof anyCtx.restore === 'function') { anyCtx.restore() } } /** 按元素框宽度估算每行最大字符数(等宽近似,兼容中英文) */ 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, baseTime: Date = new Date()): string { const type = String(element.type || '').toUpperCase() const config = element.config || {} if (isEmployeeTemplateElement(element)) { const cfgText = cfgStr(config, ['text', 'Text'], '').trim() if (cfgText && cfgText.toLowerCase() !== 'text') return applyConfigPrefix(config, cfgText) return applyConfigPrefix(config, getLoggedInEmployeeDisplayName()) } if (type === 'DATE' || type === 'TIME' || type === 'DURATION') { /** applyLiveDateTimeFieldsToTemplate 已写入 config.text 时直接展示,避免二次 resolve 把 +N Days 重置为当天 */ const baked = cfgStr(config, ['text', 'Text'], '').trim() if (baked && isLikelyResolvedDateTimeLiteral(baked)) { return applyConfigPrefix(config, baked) } const live = resolveElementDateTimeDisplay(element, baseTime) if (live != null && live.trim()) return live } 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.72) 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.72) 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) } } } /** 双下划线两线间距(px),与 Web NutritionFactsPanel 一致 */ const NUTRITION_DOUBLE_LINE_GAP = 3 function drawNutritionDivider( ctx: UniApp.CanvasContext, x1: number, x2: number, y: number, kind: NutritionDivider, ): number { if (kind === 'none') return 0 ctx.setStrokeStyle('#111827') ctx.setLineWidth(1) if (kind === 'double') { ctx.beginPath() ctx.moveTo(x1, y) ctx.lineTo(x2, y) ctx.stroke() const y2 = y + 1 + NUTRITION_DOUBLE_LINE_GAP ctx.beginPath() ctx.moveTo(x1, y2) ctx.lineTo(x2, y2) ctx.stroke() return 1 + NUTRITION_DOUBLE_LINE_GAP + 1 + 2 } ctx.beginPath() ctx.moveTo(x1, y) ctx.lineTo(x2, y) ctx.stroke() return 2 } function drawNutritionFactsOnCanvas( ctx: UniApp.CanvasContext, config: Record, boxX: number, boxY: number, boxW: number, boxH: number, ): void { const model = buildNutritionFactsViewModel(config) const pad = 6 const leftX = boxX + pad const rightX = boxX + boxW - pad const amountColRight = rightX - NUTRITION_PCT_COL_WIDTH const amountX = amountColRight - NUTRITION_AMOUNT_COL_WIDTH / 2 const pctX = rightX const maxY = boxY + boxH - pad let cursorY = boxY + pad const titleSize = Math.max(11, Math.min(18, model.titleFontSize)) const bodySize = NUTRITION_BODY_FONT_SIZE const footerSize = Math.max(8, Math.round(bodySize * 0.67)) /** 正文用 Roboto,避免 FreightSans Bold 导致整表加粗(与 Web 一致) */ const bodyCfg = { ...config, fontFamily: 'Roboto', FontFamily: 'Roboto' } ctx.setFillStyle('#ffffff') ctx.fillRect(boxX, boxY, boxW, boxH) const rowStep = (fs: number) => fs + 4 const drawLine = (label: string, value: string, fs: number, labelBold = false) => { const f = Math.max(8, Math.round(fs)) const lh = rowStep(f) if (cursorY + lh > maxY) return false ctx.setFillStyle('#111827') ctx.setFontSize(f) applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: labelBold ? 'bold' : 'normal' }, f) ctx.setTextAlign('left') ctx.fillText(label, leftX, cursorY + f) if (value) { applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: 'normal' }, f) ctx.setTextAlign('right') ctx.fillText(value, rightX, cursorY + f) } cursorY += lh return true } const drawNutrientRow = ( label: string, amount: string, pct: string, fs: number, labelBold: boolean, indent: boolean, divider: NutritionDivider, ) => { const f = Math.max(8, Math.round(fs)) const lh = rowStep(f) if (cursorY + lh > maxY) return false const labelX = leftX + (indent ? 10 : 0) ctx.setFillStyle('#111827') ctx.setFontSize(f) applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: labelBold ? 'bold' : 'normal' }, f) ctx.setTextAlign('left') ctx.fillText(label, labelX, cursorY + f) applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: 'normal' }, f) if (amount) { ctx.setTextAlign('center') ctx.fillText(amount, amountX, cursorY + f) } if (pct) { ctx.setTextAlign('right') ctx.fillText(pct, pctX, cursorY + f) } cursorY += lh if (divider !== 'none') { cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, divider) } return true } const drawFooterNote = () => { if (cursorY + footerSize + 6 > maxY) return cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'thin') ctx.setFontSize(footerSize) ctx.setTextAlign('left') const parts = DEFAULT_NUTRITION_FOOTER_NOTE.split(/(\b2000\b)/) let fx = leftX for (const part of parts) { if (!part) continue applyCanvasFontFromConfig( ctx, { ...bodyCfg, fontWeight: part === '2000' ? 'bold' : 'normal' }, footerSize, ) ctx.fillText(part, fx, cursorY + footerSize) fx += approxTextWidth(part, footerSize) } cursorY += footerSize + 2 } ctx.setFillStyle('#111827') ctx.setFontSize(Math.round(titleSize)) applyCanvasFontFromConfig(ctx, { ...config, fontWeight: 'bold' }, titleSize) ctx.setTextAlign('left') ctx.fillText('Nutrition Facts', leftX, cursorY + titleSize) cursorY += titleSize + 1 cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'double') drawLine(model.servingsLabel, model.servingsValue, bodySize) cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'thin') drawLine(model.servingSizeLabel, model.servingSizeValue, bodySize) cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'double') if (cursorY + bodySize + 3 <= maxY) { ctx.setFontSize(bodySize) applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: 'bold' }, bodySize) ctx.setTextAlign('left') ctx.fillText(model.caloriesLabel, leftX, cursorY + bodySize) applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: 'normal' }, bodySize) ctx.setTextAlign('right') ctx.fillText(model.caloriesAmountText || model.caloriesValue, rightX, cursorY + bodySize) cursorY += bodySize + 1 cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'double') } for (const row of model.rows) { if ( !drawNutrientRow( row.label, row.amountText, row.dailyValueText, bodySize, row.labelBold, row.indent, row.dividerAfter, ) ) { break } } drawFooterNote() } function strokeTemplatePaperBorder ( ctx: UniApp.CanvasContext, template: SystemLabelTemplate, cw: number, ch: number ) { const border = String(template.border || '').toLowerCase() if (border !== 'line' && border !== 'dotted') return ctx.setStrokeStyle('#374151') ctx.setLineWidth(2) const w = Math.max(0, cw - 1) const h = Math.max(0, ch - 1) if (border === 'dotted' && typeof (ctx as any).setLineDash === 'function') { ;(ctx as any).setLineDash([4, 3], 0) ctx.strokeRect(1, 1, w - 1, h - 1) ;(ctx as any).setLineDash([], 0) } else { ctx.strokeRect(1, 1, w - 1, h - 1) } } /** 元素级边框:须在背景/文字之后绘制,避免被 invert 黑底盖住 */ function strokeElementBorder( ctx: UniApp.CanvasContext, x: number, y: number, w: number, h: number, border: string | undefined, ) { const line = String(border || '').toLowerCase() if (line !== 'line' && line !== 'solid' && line !== 'dotted') return ctx.setStrokeStyle(line === 'dotted' ? '#9ca3af' : '#111827') ctx.setLineWidth(1) if (line === 'dotted' && 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) } } /** 横打:与 Web printContentLayerStyle rotate(90deg) 一致,纸张尺寸不变 */ function applyPrintContentRotation(ctx: UniApp.CanvasContext, cw: number, ch: number): void { const cx = cw / 2 const cy = ch / 2 ctx.translate(cx, cy) ctx.rotate(Math.PI / 2) ctx.translate(-cx, -cy) } /** 与屏幕预览 / 位图打印共用绘制逻辑(坐标系:设计宽 cw × ch,ctx 已 scale) */ function runLabelPreviewCanvasDraw( canvasId: string, componentInstance: any, template: SystemLabelTemplate, cw: number, ch: number, scale: number ): Promise { const sorted = sortElementsForPreview(template.elements || []) const rotateContent = normalizeTemplatePrintOrientation(template.printOrientation) === 'horizontal' return ensureLabelEditorFontsForTemplate(template).then( () => new Promise((resolve) => { const ctx = uni.createCanvasContext(canvasId, componentInstance) ctx.setFillStyle('#ffffff') ctx.scale(scale, scale) ctx.fillRect(0, 0, cw, ch) if (rotateContent) { ctx.save() applyPrintContentRotation(ctx, cw, ch) } const drawRest = (index: number) => { if (index >= sorted.length) { if (rotateContent) { ctx.restore() } strokeTemplatePaperBorder(ctx, template, cw, ch) 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) const finishElement = () => { strokeElementBorder(ctx, x, y, w, h, readElementBorder(el)) next() } if (type === 'IMAGE' || type === 'LOGO') { const src = resolveMediaUrlForApp(cfgStr(config, ['src', 'url', 'Src', 'Url'])) const boxW = w || 80 const boxH = h || 40 const scaleMode = readImageScaleMode(config) if (src) { uni.getImageInfo({ src, success: (info) => { try { const rect = computeImageDrawRect( boxW, boxH, Number(info.width) || 0, Number(info.height) || 0, scaleMode, ) ctx.drawImage(info.path, x + rect.dx, y + rect.dy, rect.dw, rect.dh) } catch (_) { ctx.setStrokeStyle('#cccccc') ctx.setLineWidth(1) ctx.strokeRect(x, y, boxW, boxH) } finishElement() }, fail: () => { ctx.setStrokeStyle('#cccccc') ctx.strokeRect(x, y, boxW, boxH) finishElement() }, }) return } finishElement() return } if (type === 'NUTRITION') { drawNutritionFactsOnCanvas(ctx, config, x, y, Math.max(40, w || 220), Math.max(80, h || 280)) finishElement() 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 elementRotation = readElementRotation(el) const configOrientation = cfgStr(config, ['orientation', 'Orientation'], 'horizontal').toLowerCase() const orientation = elementRotation === 'vertical' ? 'vertical' : configOrientation const showText = String(config.showText ?? config.ShowText ?? 'true').toLowerCase() !== 'false' drawBarcodeLikePreview(ctx, x, y, w || 140, h || 56, d, { orientation, showText }) finishElement() return } if (type === 'QRCODE' && d && !storedValueLooksLikeImagePath(d)) { drawQrCodePreview(ctx, x, y, w || 96, h || 96, d, cfgStr(config, ['errorLevel', 'ErrorLevel'], 'M')) finishElement() 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() } finishElement() }, fail: () => { drawQrBarcodePlaceholder() finishElement() }, }) return } } drawQrBarcodePlaceholder() finishElement() return } const text = previewTextForElement(el) if (text && !isGraphicOnlyType(type)) { const rotation = String(el.rotation ?? (el as any).Rotation ?? 'horizontal').toLowerCase() const drawAt = (bx: number, by: number, bw: number, bh: number) => { const fontSize = readFontSize(config) const inverted = readInvertColors(config) if (inverted) { ctx.setFillStyle('#000000') ctx.fillRect(bx, by, bw, bh) ctx.setFillStyle('#ffffff') } else { ctx.setFillStyle(readFillColor(config)) } ctx.setFontSize(fontSize) applyCanvasFontFromConfig(ctx, config, fontSize) const fontWeight = readFontWeight(config) if (typeof (ctx as any).setFontWeight === 'function') { ;(ctx as any).setFontWeight(fontWeight === 'bold' ? 'bold' : 'normal') } const align = readTextAlign(config) const fillColor = inverted ? '#ffffff' : readFillColor(config) const pad = 2 const innerW = Math.max(0, bw - pad * 2) const innerH = Math.max(fontSize, bh - pad * 2) let tx = bx + pad if (align === 'center') tx = bx + bw / 2 else if (align === 'right') tx = bx + bw - pad ctx.setTextAlign(align === 'center' ? 'center' : align === 'right' ? 'right' : 'left') const lineHeight = fontSize + Math.max(2, Math.round(fontSize * 0.15)) const isDateTimeType = type === 'DATE' || type === 'TIME' || type === 'DURATION' const lines = isDateTimeType ? [String(text).replace(/\s+/g, ' ').trim()] : wrapTextToWidth(text, maxCharsPerLine(innerW, fontSize)) const maxLines = isDateTimeType ? 1 : innerH >= fontSize ? Math.max(1, Math.floor(innerH / lineHeight)) : lines.length const visibleLines = lines.slice(0, maxLines) const blockHeight = visibleLines.length * lineHeight const verticalAlign = readVerticalAlign(config) const verticalOffset = computeVerticalTextBlockOffset(innerH, blockHeight, verticalAlign) const startY = by + pad + verticalOffset + fontSize visibleLines.forEach((ln, li) => { drawStyledTextLine(ctx, ln, tx, startY + li * lineHeight, fontSize, align, config, fillColor) }) ctx.setTextAlign('left') } if (rotation === 'vertical') { const anyCtx = ctx as any if (typeof anyCtx.save === 'function' && typeof anyCtx.rotate === 'function') { anyCtx.save() anyCtx.translate(x + w / 2, y + h / 2) anyCtx.rotate(-Math.PI / 2) drawAt(-h / 2, -w / 2, h, w) anyCtx.restore() } else { drawAt(x, y, w, h) } } else { drawAt(x, y, w, h) } } finishElement() } 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 一致。 */ /** 调整 canvas :width/:height 后等待绘图缓冲区就绪,避免光栅导出高度不足导致底部条码/日期被裁切 */ export async function settleAfterLabelCanvasResize (): Promise { await new Promise((r) => setTimeout(r, 16)) await new Promise((resolve) => { if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(() => requestAnimationFrame(() => resolve())) } else { setTimeout(() => resolve(), 32) } }) await new Promise((r) => setTimeout(r, 80)) } export function getLabelPrintRasterLayout( template: SystemLabelTemplate, maxWidthDots: number, printDpi = 203, opts?: { contentHeightPx?: number }, ): { 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 fullH = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) const ch = opts?.contentHeightPx != null && opts.contentHeightPx > 0 ? Math.max(40, Math.round(opts.contentHeightPx)) : fullH 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)), } }