import type { RawImageDataSource, SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' import { resolveMediaUrlForApp, storedValueLooksLikeImagePath } from '../resolveMediaUrl' import { sortElementsForPreview, normalizeTemplatePrintOrientation } from './normalizePreviewTemplate' import { resolveLabelDesignCanvasPx } from '../print/templatePhysicalMm' import { resolveElementDateTimeDisplay, isLikelyResolvedDateTimeLiteral, isCorruptedDateDisplayFormat, isStoredPrintInputOffsetPayload, sanitizeDateElementConfig, resolveWeekdayDisplayForElement, isUniAppDateTimeOffsetField, isWeekdayBarElement, isPrintInputLiteralField, resolvePrintInputLiteralDisplay, } 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, elementRotationDegrees, isShortSingleLineTextBox, type TextVerticalAlign, } 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') } /** 底部黑条:模板约定 verticalAlign=center,在元素框内居中铺黑底(与 Web flex 一致) */ function resolveDrawVerticalAlign( config: Record, el: SystemTemplateElementBase, opts: { invertedBar: boolean; weekdayBar: boolean }, ): TextVerticalAlign { const merged = { ...config, verticalAlign: config.verticalAlign ?? config.VerticalAlign ?? (el as Record).verticalAlign ?? (el as Record).VerticalAlign, } const align = readVerticalAlign(merged) if (opts.invertedBar && opts.weekdayBar) { if (align === 'top') return 'center' return align } return align } function lineHeightForTextElement( printFontSize: number, isDateTimeType: boolean, ): number { return isDateTimeType ? Math.max(printFontSize + 2, Math.round(printFontSize * 1.2)) : Math.max(printFontSize + 2, Math.round(printFontSize * 1.25)) } /** * Web AlignedTextBox + 黑底行:行盒高度略小于 line-height 1.2,在元素框内垂直居中。 * layout 用较薄行盒算偏移,绘制用略高条带承托粗体。 */ function layoutInvertedWeekdayStrip( by: number, bh: number, printFontSize: number, verticalAlign: TextVerticalAlign, ): { stripTop: number; stripHeight: number; textY: number } { const layoutH = Math.max(printFontSize + 2, Math.round(printFontSize * 1.12)) const stripHeight = Math.max(layoutH, Math.round(printFontSize * 1.22)) const effectiveAlign = verticalAlign === 'top' ? 'center' : verticalAlign const verticalOffset = computeVerticalTextBlockOffset(bh, layoutH, effectiveAlign) const stripTop = by + verticalOffset const textY = stripTop + Math.round(stripHeight * 0.74) return { stripTop, stripHeight, textY } } /** 单行在元素框内垂直对齐(与 Web flex + verticalAlign 一致);多行仍用 alphabetic baseline */ function computeLineDrawY( by: number, bh: number, li: number, fittedLineHeight: number, visibleLineCount: number, printFontSize: number, verticalAlign: TextVerticalAlign, ): { y: number; baseline: 'middle' | 'alphabetic' } { const blockHeight = visibleLineCount * fittedLineHeight const verticalOffset = computeVerticalTextBlockOffset(bh, blockHeight, verticalAlign) const lineTop = by + verticalOffset + li * fittedLineHeight if (visibleLineCount === 1 && verticalAlign === 'center') { return { y: by + bh / 2, baseline: 'middle' } } if (visibleLineCount === 1 && verticalAlign === 'bottom') { return { y: by + bh - Math.max(1, Math.round(printFontSize * 0.12)), baseline: 'alphabetic', } } if (visibleLineCount > 1) { return { y: lineTop + fittedLineHeight / 2, baseline: 'middle' } } return { y: lineTop + Math.round(printFontSize * 0.82), baseline: 'alphabetic' } } /** middle 基线时 y 在字高中心,下划线贴在数字底部略下方 */ function computeUnderlineDrawY( y: number, fontSize: number, baseline: 'middle' | 'alphabetic', ): number { if (baseline === 'middle') { /** 数字 cap-height 约 0.72em,自 center 到字底约 0.36em */ return y + Math.round(fontSize * 0.36) + Math.max(1, Math.round(fontSize * 0.04)) } return y + Math.max(1, Math.round(fontSize * 0.1)) } function readCanvasFontWeight(config: Record): 'normal' | 'bold' { if (readFontWeight(config) === 'bold') return 'bold' const family = resolveLabelEditorFontFamily(config).toLowerCase() if (family.includes('bold')) return 'bold' return 'normal' } function applyCanvasFontFromConfig( ctx: UniApp.CanvasContext, config: Record, fontSize: number, ): void { const fontFamily = resolveLabelEditorFontFamily(config) const anyCtx = ctx as any const weight = readCanvasFontWeight(config) 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 canvasSupportsMeasureText(ctx: UniApp.CanvasContext): boolean { return typeof (ctx as any).measureText === 'function' } function measureCanvasLineWidth( ctx: UniApp.CanvasContext, text: string, fontSize: number, config: Record, ): number { if (canvasSupportsMeasureText(ctx)) { try { applyCanvasFontFromConfig(ctx, config, fontSize) const w = Number((ctx as any).measureText(String(text)).width) if (Number.isFinite(w) && w > 0) return w } catch { /* fall through */ } } return approxTextWidth(text, fontSize) } function pushLongWordByMeasuredWidth( ctx: UniApp.CanvasContext, word: string, maxWidth: number, fontSize: number, config: Record, out: string[], ): string { let buf = '' for (let i = 0; i < word.length; i++) { const ch = word.charAt(i) const trial = buf + ch if (buf && measureCanvasLineWidth(ctx, trial, fontSize, config) > maxWidth) { out.push(buf) buf = ch } else { buf = trial } } return buf } function flushMeasuredRemainder( ctx: UniApp.CanvasContext, remainder: string, maxWidth: number, fontSize: number, config: Record, out: string[], ): void { let rest = String(remainder ?? '') let guard = 0 const guardMax = Math.max(64, rest.length * 4) while (rest.length > 0 && guard++ < guardMax) { const before = out.length rest = pushLongWordByMeasuredWidth(ctx, rest, maxWidth, fontSize, config, out) if (!rest) break if (out.length === before) { /** 单字宽于行宽或 push 未产出新行:强制推进 1 字符,避免 while(rest) 死循环 */ out.push(rest.slice(0, 1)) rest = rest.slice(1) continue } if (measureCanvasLineWidth(ctx, rest, fontSize, config) <= maxWidth) { out.push(rest) break } } if (rest.length > 0 && guard >= guardMax) { out.push(rest) } } /** 优先 measureText 按真实宽度断行;breakAll 与 Web `break-all` 一致 */ function wrapTextToCanvasWidth( ctx: UniApp.CanvasContext, text: string, innerWidthPx: number, fontSize: number, config: Record, breakAll = false, ): string[] { const maxW = Math.max(4, innerWidthPx) const rawLines = String(text ?? '').split(/\r?\n/) const out: string[] = [] for (const segment of rawLines) { const s = String(segment ?? '') if (!s.trim()) { out.push(s) continue } if (measureCanvasLineWidth(ctx, s, fontSize, config) <= maxW) { out.push(s) continue } if (!canvasSupportsMeasureText(ctx)) { out.push(...wrapTextToWidth(s, maxCharsPerLine(maxW, fontSize))) continue } if (breakAll) { flushMeasuredRemainder(ctx, s, maxW, fontSize, config, out) continue } const words = s.trim().split(/\s+/).filter(Boolean) let cur = '' for (const word of words) { const trial = cur ? `${cur} ${word}` : word if (measureCanvasLineWidth(ctx, trial, fontSize, config) <= maxW) { cur = trial } else { if (cur) out.push(cur) if (measureCanvasLineWidth(ctx, word, fontSize, config) <= maxW) { cur = word } else { cur = pushLongWordByMeasuredWidth(ctx, word, maxW, fontSize, config, out) } } } if (cur) out.push(cur) } return out.length ? out : [''] } /** 黑底/静态文案行高:与 Web leading-tight(≈1.25)一致,避免矮框多行末行被 clip */ function invertedStaticLineHeight(fontSize: number): number { return lineHeightForTextElement(fontSize, false) } function shouldBreakAllTextWrap(_type: string, invertedStatic: boolean): boolean { /** 仅黑底窄框标签需 break-all;长地址等普通 TEXT_STATIC 用词边界换行,避免逐字 measureText 卡死 */ return invertedStatic } function maxLinesForBox( layoutBoxH: number, fittedLineHeight: number, printFontSize: number, ): number { if (layoutBoxH < printFontSize) return 1 const descentPad = Math.max(2, Math.round(printFontSize * 0.14)) return Math.max(1, Math.floor((layoutBoxH + descentPad) / fittedLineHeight)) } function fitFontSizeToInnerWidth( ctx: UniApp.CanvasContext, line: string, innerW: number, fontSize: number, config: Record, ): number { if (!line || innerW <= 0) return fontSize if (!canvasSupportsMeasureText(ctx)) { const w = approxTextWidth(line, fontSize) if (w <= innerW) return fontSize return Math.max(8, Math.floor(fontSize * (innerW / w) * 0.98)) } let size = fontSize for (let i = 0; i < 5; i++) { const w = measureCanvasLineWidth(ctx, line, size, config) if (w <= innerW || w <= 0) return size size = Math.max(8, Math.floor(size * (innerW / w) * 0.98)) } return Math.max(8, size) } function resolveTextLinesForDraw( ctx: UniApp.CanvasContext, text: string, innerW: number, layoutBoxH: number, printFontSize: number, fittedLineHeight: number, config: Record, opts: { breakAll: boolean; isDateTimeType: boolean }, ): { lines: string[]; drawFontSize: number } { const trimmed = String(text ?? '').trim() if (!trimmed) return { lines: [''], drawFontSize: printFontSize } if (opts.isDateTimeType) { return { lines: [trimmed.replace(/\s+/g, ' ')], drawFontSize: printFontSize } } const preferSingleLine = !opts.breakAll && !trimmed.includes('\n') && isShortSingleLineTextBox(layoutBoxH, printFontSize, trimmed, { breakAll: opts.breakAll }) if (preferSingleLine) { const drawFontSize = fitFontSizeToInnerWidth(ctx, trimmed, innerW, printFontSize, config) return { lines: [trimmed], drawFontSize } } return { lines: wrapTextToCanvasWidth(ctx, trimmed, innerW, printFontSize, config, opts.breakAll), drawFontSize: printFontSize, } } function computeVisibleTextLines( lines: string[], layoutBoxH: number, fittedLineHeight: number, printFontSize: number, isDateTimeType: boolean, ): string[] { if (isDateTimeType) return lines.slice(0, 1) if (layoutBoxH < printFontSize) return lines.slice(0, 1) const maxLines = Math.min(lines.length, maxLinesForBox(layoutBoxH, fittedLineHeight, printFontSize)) return lines.slice(0, maxLines) } function drawStyledTextLine( ctx: UniApp.CanvasContext, line: string, tx: number, y: number, fontSize: number, align: string, config: Record, fillColor: string, emphasizeForPrint = false, baseline: 'middle' | 'alphabetic' = 'alphabetic', ): 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 (typeof anyCtx.setTextBaseline === 'function') { anyCtx.setTextBaseline(baseline) } const drawFill = () => { if (emphasizeForPrint) { const stroke = Math.max(0.5, fontSize * 0.04) for (const [ox, oy] of [[0, 0], [stroke, 0], [-stroke, 0], [0, stroke], [0, -stroke]] as const) { ctx.fillText(line, tx + ox, y + oy) } return } ctx.fillText(line, tx, y) } if (useSkewFallback && typeof anyCtx.save === 'function') { anyCtx.save() anyCtx.transform(1, 0, -0.25, 1, tx * 0.08, 0) } drawFill() 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 } const underlineY = computeUnderlineDrawY(y, fontSize, baseline) ctx.setStrokeStyle(fillColor) ctx.setLineWidth(Math.max(1, Math.round(fontSize * 0.06))) ctx.beginPath() ctx.moveTo(x1, underlineY) ctx.lineTo(x2, underlineY) 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 resolveDateTimePreviewText ( element: SystemTemplateElementBase, baseTime: Date = new Date(), ): string { if (isPrintInputLiteralField(element)) { return resolvePrintInputLiteralDisplay(element) } if (isWeekdayBarElement(element)) { return applyConfigPrefix( element.config || {}, resolveWeekdayDisplayForElement(element, baseTime), ) } const config = element.config || {} const previewFormatted = cfgStr(config, ['__previewFormatted', '__PreviewFormatted'], '').trim() if ( previewFormatted && !isCorruptedDateDisplayFormat(previewFormatted) && !isStoredPrintInputOffsetPayload(previewFormatted) ) { return applyConfigPrefix(config, previewFormatted) } const sanitizedCfg = { ...config } as Record sanitizeDateElementConfig(element, sanitizedCfg) try { const live = resolveElementDateTimeDisplay({ ...element, config: sanitizedCfg }, baseTime) if (live != null && live.trim() && !isCorruptedDateDisplayFormat(live) && !isStoredPrintInputOffsetPayload(live)) { return applyConfigPrefix(sanitizedCfg, live) } } catch (err) { console.warn('[labelPreview] resolveElementDateTimeDisplay failed', element.id, err) } return '' } function previewTextForElement(element: SystemTemplateElementBase, baseTime: Date = new Date()): string { const type = String(element.type || '').toUpperCase() const config = element.config || {} if (isPrintInputLiteralField(element)) { return resolvePrintInputLiteralDisplay(element) } if (isWeekdayBarElement(element)) { return applyConfigPrefix(config, resolveWeekdayDisplayForElement(element, baseTime)) } 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') { return resolveDateTimePreviewText(element, baseTime) } 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) { const joined = arr.join(', ') if (txt.trim() && arr.some((v) => txt.includes(v))) return txt return applyConfigPrefix(config, joined) } return '' } if (vst === 'PRINT_INPUT' && !(inputType === 'options' || hasDict)) { if ( inputType === 'date' || inputType === 'datetime' || isUniAppDateTimeOffsetField(element) ) { return resolveDateTimePreviewText(element, baseTime) } 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 literalNumberOrText = inputType === 'number' || inputType === 'text' || isPrintInputLiteralField(element) if (!literalNumberOrText && isStoredPrintInputOffsetPayload(body)) return '' if (isCorruptedDateDisplayFormat(body)) return '' 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', ]) if (isStoredPrintInputOffsetPayload(body) || isCorruptedDateDisplayFormat(body)) return '' 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; fontSize?: number }, ): void { const bw = Math.max(8, w || 140) const bh = Math.max(8, h || 56) const showText = options?.showText !== false const fontSize = Math.max(8, Math.round(Number(options?.fontSize ?? 14) || 14)) const labelReserve = showText ? Math.max(12, fontSize + 4) : 4 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 ? labelReserve : 0 const barH = Math.max(20, bh - textH - pad) 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.85) ctx.fillRect(cursor, y + pad, rw, barH) } cursor += moduleW } if (showText && txt) { ctx.setFontSize(fontSize) ctx.setTextAlign('center') ctx.fillText(txt, x + bw / 2, y + bh - Math.max(2, Math.round(fontSize * 0.2))) ctx.setTextAlign('left') } return } // vertical:条码在左,data 竖排在右 const textBandW = showText && txt ? Math.max(14, Math.round(fontSize + 6)) : 0 const barW = Math.max(12, bw - textBandW - pad * 2) const innerH = Math.max(12, 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.85) ctx.fillRect(x + pad, cursorY, barW, rh) } cursorY += moduleH } if (showText && txt) { const font = fontSize 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' } const rowStep = (fs: number) => fs + 3 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, drawOptions: { forPrint?: boolean; baseTime?: Date } = {}, ): Promise { const forPrint = drawOptions.forPrint === true const baseTime = drawOptions.baseTime ?? new Date() 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' const barcodeFontSize = Number(config.fontSize ?? config.FontSize ?? 14) || 14 drawBarcodeLikePreview(ctx, x, y, w || 140, h || 56, d, { orientation, showText, fontSize: barcodeFontSize, }) 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 weekdayBar = isWeekdayBarElement(el) let finalText = '' if (weekdayBar) { try { finalText = resolveWeekdayDisplayForElement(el, baseTime) } catch (err) { console.warn('[labelPreview] resolveWeekdayDisplayForElement failed', el.id, err) } } else { try { finalText = previewTextForElement(el, baseTime) } catch (err) { console.warn('[labelPreview] previewTextForElement failed', el.id, err) } if (finalText && isCorruptedDateDisplayFormat(finalText)) { finalText = '' } } if ((finalText || weekdayBar) && !isGraphicOnlyType(type)) { if (!finalText && weekdayBar) { finalText = resolveWeekdayDisplayForElement(el, baseTime) } const rotationDegrees = elementRotationDegrees(el as any) const drawAt = (bx: number, by: number, bw: number, bh: number) => { const anyCtx = ctx as any if (typeof anyCtx.save === 'function') anyCtx.save() if (typeof anyCtx.beginPath === 'function' && typeof anyCtx.rect === 'function') { anyCtx.beginPath() anyCtx.rect(bx, by, bw, bh) if (typeof anyCtx.clip === 'function') anyCtx.clip() } const fontSize = readFontSize(config) const invertedBar = readInvertColors(config) const printFontSize = invertedBar && forPrint ? fontSize + Math.max(1, Math.round(fontSize * 0.08)) : fontSize const align = readTextAlign(config) const fillColor = invertedBar ? '#ffffff' : readFillColor(config) /** 与 Web AlignedTextBox 的 Tailwind px-1 一致:仅水平 4px,垂直无 padding */ const hPad = 4 const innerW = Math.max(0, bw - hPad * 2) const innerH = Math.max(printFontSize, bh) let tx = bx + hPad if (align === 'center') tx = bx + bw / 2 else if (align === 'right') tx = bx + bw - hPad const isDateTimeType = type === 'DATE' || type === 'TIME' || type === 'DURATION' || weekdayBar const invertedStatic = invertedBar && !weekdayBar const breakAll = shouldBreakAllTextWrap(type, invertedStatic) const layoutBoxH = invertedBar ? bh : innerH const baseLineHeight = invertedStatic ? invertedStaticLineHeight(printFontSize) : lineHeightForTextElement(printFontSize, isDateTimeType) const { lines, drawFontSize } = resolveTextLinesForDraw( ctx, finalText, innerW, layoutBoxH, printFontSize, baseLineHeight, config, { breakAll, isDateTimeType }, ) ctx.setFontSize(drawFontSize) applyCanvasFontFromConfig(ctx, config, drawFontSize) const fontWeight = invertedBar && forPrint ? 'bold' : readCanvasFontWeight(config) if (typeof anyCtx.setFontWeight === 'function') { anyCtx.setFontWeight(fontWeight === 'bold' ? 'bold' : 'normal') } ctx.setTextAlign(align === 'center' ? 'center' : align === 'right' ? 'right' : 'left') const fittedLineHeight = isDateTimeType && !invertedBar && bh > 0 ? Math.min( invertedStatic ? invertedStaticLineHeight(drawFontSize) : lineHeightForTextElement(drawFontSize, isDateTimeType), bh, ) : invertedStatic ? invertedStaticLineHeight(drawFontSize) : lineHeightForTextElement(drawFontSize, isDateTimeType) const visibleLines = computeVisibleTextLines( lines, layoutBoxH, fittedLineHeight, drawFontSize, isDateTimeType, ) const verticalAlign = resolveDrawVerticalAlign(config, el, { invertedBar, weekdayBar }) const weekdayStrip = invertedBar && weekdayBar ? layoutInvertedWeekdayStrip(by, bh, printFontSize, verticalAlign) : null if (weekdayStrip) { ctx.setFillStyle('#000000') ctx.fillRect(bx + hPad, weekdayStrip.stripTop, innerW, weekdayStrip.stripHeight) ctx.setFillStyle('#ffffff') } else if (invertedBar) { ctx.setFillStyle('#000000') /** 黑底铺满元素框(与 Web invert 控件视觉尺寸一致),避免只画文字行高导致「控件不完整」 */ ctx.fillRect(bx + hPad, by, innerW, bh) ctx.setFillStyle('#ffffff') } else { ctx.setFillStyle(fillColor) } visibleLines.forEach((ln, li) => { let textY: number let baseline: 'middle' | 'alphabetic' = 'alphabetic' if (weekdayStrip) { textY = weekdayStrip.textY + li * weekdayStrip.stripHeight } else { const pos = computeLineDrawY( by, layoutBoxH, li, fittedLineHeight, visibleLines.length, drawFontSize, verticalAlign, ) textY = pos.y baseline = pos.baseline } drawStyledTextLine( ctx, ln, tx, textY, drawFontSize, align, config, fillColor, invertedBar && forPrint, baseline, ) }) if (typeof (ctx as any).setTextBaseline === 'function') { ;(ctx as any).setTextBaseline('alphabetic') } ctx.setTextAlign('left') if (typeof anyCtx.restore === 'function') anyCtx.restore() } if (rotationDegrees !== 0) { const anyCtx = ctx as any if (typeof anyCtx.save === 'function' && typeof anyCtx.rotate === 'function') { const isQuarterTurn = rotationDegrees === -90 || rotationDegrees === 90 anyCtx.save() anyCtx.translate(x + w / 2, y + h / 2) anyCtx.rotate((rotationDegrees * Math.PI) / 180) drawAt( isQuarterTurn ? -h / 2 : -w / 2, isQuarterTurn ? -w / 2 : -h / 2, isQuarterTurn ? h : w, isQuarterTurn ? w : h, ) 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, { forPrint: true }).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) }) ) } function countNonWhiteCanvasPixels (data: ArrayLike, pixelCount: number): number { let dark = 0 for (let i = 0; i < pixelCount; i++) { const idx = i * 4 const red = Number(data[idx] ?? 255) const green = Number(data[idx + 1] ?? 255) const blue = Number(data[idx + 2] ?? 255) const alpha = Number(data[idx + 3] ?? 255) if (alpha > 0 && (red < 250 || green < 250 || blue < 250)) dark++ } return dark } function captureLabelPreviewCanvasImageData ( canvasId: string, componentInstance: any, outW: number, outH: number, settleMs = 150, ): Promise { return 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, ) }, settleMs) }) } /** * 与 `shouldRasterPrintViaCanvasImageData()` 配套:内置 UPOS 等机型不走 Bitmap int[],与 Test Print 一致用 canvasGetImageData。 */ export async 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 const pixelCount = Math.max(1, outW * outH) const drawAndCapture = async (settleMs: number) => { await runLabelPreviewCanvasDraw(canvasId, componentInstance, template, cw, ch, scale, { forPrint: true }) return captureLabelPreviewCanvasImageData(canvasId, componentInstance, outW, outH, settleMs) } let imageData = await drawAndCapture(150) if (countNonWhiteCanvasPixels(imageData.data, pixelCount) <= 0) { await settleAfterLabelCanvasResize() imageData = await drawAndCapture(220) } if (countNonWhiteCanvasPixels(imageData.data, pixelCount) <= 0) { throw new Error(`CANVAS_RASTER_EMPTY:size=${outW}x${outH}`) } return imageData } /** * 按打印机最大宽度(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, ): { cw: number, ch: number, outW: number, outH: number, scale: number } { const { cw, ch } = resolveLabelDesignCanvasPx(template) 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 { cw, ch } = resolveLabelDesignCanvasPx(template) const scale = Math.min(1, maxDisplayWidthPx / cw) return { width: Math.max(1, Math.round(cw * scale)), height: Math.max(1, Math.round(ch * scale)), } }