nativeTemplateElementSupport.ts 9.28 KB
/**
 * 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<string, unknown>
  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<string, unknown>
  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<string, any>
    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<string, unknown>
      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
}