/** * Android NativeTemplateCommandBuilder 仅处理:TEXT_*、QRCODE、BARCODE、IMAGE、 * 以及 border=line 的 BLANK;其余类型在原生路径下会被静默跳过(与画布预览不一致)。 */ import type { LabelTemplateData, SystemLabelTemplate, SystemTemplateElementBase, } from './types/printer' import { formatBarcodeValueForTsc, normalizeBarcodeType } from '../barcodeFormat' import { applyTemplateData } from './templateRenderer' function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean { const type = String(el.type || '').toUpperCase() if (type.startsWith('TEXT_')) return true if (type === 'NUTRITION') return true if (type === 'QRCODE' || type === 'IMAGE') return true if (type === 'BARCODE') return true if (type === 'BLANK') return true return false } /** 原生营养表:严格使用模板 height,打印时再按 dpi 缩放;勿在 JS 侧抬高 height 以免压住下方 DATE */ function prepareNutritionElementForNativePrint (el: SystemTemplateElementBase): SystemTemplateElementBase { const cfg = { ...(el.config || {}) } as Record const x = Math.max(0, Number(el.x) || 0) const y = Math.max(0, Number(el.y) || 0) const w = Math.max(40, Number(el.width) || 0) const h = Math.max(40, Number(el.height) || 72) cfg.nativePrintHeight = h cfg.NativePrintHeight = h cfg.nativePadLeft = Number(cfg.nativePadLeft ?? 2) cfg.nativePadRight = Number(cfg.nativePadRight ?? 4) cfg.nutritionTitleBold = false cfg.nutritionBodyBold = false return { ...el, x, y, width: w, height: h, config: cfg, } } /** 原生打印按 dpi 放大营养表后,保证 DATE/TIME/BARCODE 在营养表底边之下(与预览留白一致) */ function resolveNativeLayoutCollisions ( elements: SystemTemplateElementBase[], ): SystemTemplateElementBase[] { const nutrition = elements.find((el) => String(el.type || '').toUpperCase() === 'NUTRITION') if (!nutrition) return elements const nutY = Number(nutrition.y) || 0 const nutH = Number(nutrition.height) || 0 /** 设计 px 最小间距;预览里常见 8–12px */ const minGapPx = 10 const reservedBottom = nutY + nutH + minGapPx return elements.map((el) => { if (el.id === nutrition.id) return el const y = Number(el.y) || 0 if (y < reservedBottom && y >= nutY - 1) { return { ...el, y: reservedBottom } } return el }) } function prepareBarcodeElementForNativePrint (el: SystemTemplateElementBase): SystemTemplateElementBase { const cfg = { ...(el.config || {}) } as Record const barcodeType = normalizeBarcodeType(cfg.barcodeType ?? cfg.BarcodeType) const raw = String( cfg.data ?? cfg.Data ?? cfg.value ?? cfg.Value ?? cfg.barcodeData ?? cfg.BarcodeData ?? '' ).trim() const data = formatBarcodeValueForTsc(raw, barcodeType) /** 人读数字与预览一致(1234),编码串(A1234B)仅给原生画条用 */ cfg.barcodeDisplayText = raw cfg.BarcodeDisplayText = raw if (data) { cfg.data = data cfg.Data = data cfg.value = data cfg.Value = data } /** CODABAR 在 Virtual BT / 佳博上 TSC BARCODE 易失败,改走与预览一致的位图条 */ if (barcodeType === 'CODABAR') { cfg.nativeBarcodeBitmap = true cfg.NativeBarcodeBitmap = true } return { ...el, config: cfg } } /** * 将 WEIGHT / DATE / TIME / DURATION 转为 TEXT_STATIC(展示文案与合并后的 config.text 一致), * LOGO → IMAGE,使同一套模板可走 native printTemplate,避免仅因元素类型名而整页光栅(进度长期停在 ~12–14%)。 */ export function normalizeTemplateForNativeFastJob ( template: SystemLabelTemplate, data: LabelTemplateData ): SystemLabelTemplate { const now = new Date() const pad2 = (n: number): string => String(n).padStart(2, '0') const formatDateByPreset = (fmt: string, d: Date): string => { const yyyy = String(d.getFullYear()) const yy = yyyy.slice(-2) const mm = pad2(d.getMonth() + 1) const dd = pad2(d.getDate()) const hh = pad2(d.getHours()) const min = pad2(d.getMinutes()) switch (fmt) { case 'DD/MM/YYYY': return `${dd}/${mm}/${yyyy}` case 'MM/DD/YYYY': return `${mm}/${dd}/${yyyy}` case 'DD/MM/YY': return `${dd}/${mm}/${yy}` case 'MM/DD/YY': return `${mm}/${dd}/${yy}` case 'MM/YY': return `${mm}/${yy}` case 'MM/DD': return `${mm}/${dd}` case 'MM': return mm case 'DD': return dd case 'YY': return yy case 'YYYY-MM-DD': return `${yyyy}-${mm}-${dd}` case 'YYYY-MM-DD HH:mm': return `${yyyy}-${mm}-${dd} ${hh}:${min}` case 'HH:mm': return `${hh}:${min}` default: return String(fmt || '') .replace('YYYY', yyyy) .replace('YY', yy) .replace('MM', mm) .replace('DD', dd) .replace('HH', hh) .replace('mm', min) } } const extras: SystemTemplateElementBase[] = [] const elements = (template.elements || []).map((el) => { const type = String(el.type || '').toUpperCase() const config = (el.config || {}) as Record if (type === 'LOGO') { return { ...el, type: 'IMAGE' as typeof el.type } } if (type === 'WEIGHT' || type === 'DATE' || type === 'TIME' || type === 'DURATION') { let text = '' /** 与 renderLabelPreviewCanvas.previewTextForElement 一致:后端 AUTO_DB 的 DATE/TIME 常把算好的展示串写在 format 而非 text */ let raw = String(config.text ?? config.Text ?? '').trim() const vst = String(el.valueSourceType || '').toUpperCase() const inputType = String(config.inputType ?? config.InputType ?? '').toLowerCase() if (type === 'DATE') { if (raw) { text = applyTemplateData(raw, data) } else if (vst === 'PRINT_INPUT' && (inputType === 'date' || inputType === 'datetime')) { const fmt = String(config.format ?? config.Format ?? (inputType === 'datetime' ? 'YYYY-MM-DD HH:mm' : 'DD/MM/YYYY')).trim() text = fmt } else { const fmt = String(config.format ?? config.Format ?? 'DD/MM/YYYY').trim() || 'DD/MM/YYYY' const offset = Number(config.offsetDays ?? config.OffsetDays ?? 0) || 0 const d = new Date(now.getTime()) d.setDate(d.getDate() + offset) text = formatDateByPreset(fmt, d) } } else if (type === 'TIME') { if (raw) { text = applyTemplateData(raw, data) } else if (vst === 'PRINT_INPUT') { text = String(config.format ?? config.Format ?? 'HH:mm').trim() || 'HH:mm' } else { text = formatDateByPreset('HH:mm', now) } } else if (type === 'DURATION') { if (raw) { text = applyTemplateData(raw, data) } else { const unit = String(config.format ?? config.Format ?? 'Days').trim() || 'Days' const rawV = config.durationValue ?? config.value ?? config.offsetDays ?? config.DurationValue ?? config.Value ?? config.OffsetDays const val = Number.isFinite(Number(rawV)) ? Number(rawV) : 0 text = `${val} ${unit}`.trim() } } else if (type === 'WEIGHT') { if (!raw) raw = String(config.value ?? config.Value ?? '').trim() if (raw) text = applyTemplateData(raw, data) } if (!text && type === 'WEIGHT') { const v = String(config.value ?? config.Value ?? '') const u = String(config.unit ?? config.Unit ?? '') if (v && u && !v.endsWith(u)) text = `${v}${u}` else text = v || u } return { ...el, type: 'TEXT_STATIC' as typeof el.type, config: { ...config, text, nativeSourceType: type }, } } if (type === 'NUTRITION') { return prepareNutritionElementForNativePrint(el) } if (type === 'BARCODE') { const prepared = prepareBarcodeElementForNativePrint(el) const pcfg = { ...(prepared.config || {}) } as Record const human = String(pcfg.barcodeDisplayText ?? pcfg.BarcodeDisplayText ?? '').trim() const showHuman = String(pcfg.showText ?? pcfg.ShowText ?? 'true').toLowerCase() !== 'false' if (human && showHuman) { pcfg.showText = false pcfg.ShowText = false extras.push({ id: `${String(prepared.id || 'barcode')}_label`, type: 'TEXT_STATIC', x: Number(prepared.x) || 0, y: (Number(prepared.y) || 0) + (Number(prepared.height) || 40) + 6, width: Number(prepared.width) || 140, height: 20, rotation: prepared.rotation ?? 'horizontal', border: 'none', config: { text: human, fontSize: 12, textAlign: 'center', TextAlign: 'center', forceRasterText: true, }, } as SystemTemplateElementBase) } return { ...prepared, config: pcfg } } return el }) const merged = resolveNativeLayoutCollisions([...elements, ...extras]) return { ...template, elements: merged } } /** 存在任一原生不支持的元素时,预览打印应走光栅,避免「成功但缺内容/不出纸」与画布不一致 */ export function templateHasUnsupportedNativeFastElements (template: SystemLabelTemplate): boolean { for (const el of template.elements || []) { if (!isElementHandledByNativeFastPrinter(el)) return true } return false }