nativeTemplateElementSupport.ts 10.1 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'
import {
  isCorruptedDateDisplayFormat,
  parseStoredPrintInputOffset,
  resolveElementDateTimeDisplay,
} from '../labelPreview/printInputOffset'
import { readInvertColors } from '../invertColorsConfig'
import { normalizeTemplatePrintOrientation } from '../labelPreview/normalizePreviewTemplate'
import {
  readElementBorder,
  readElementRotation,
  readFontStyle,
  readFontWeight,
  readTextDecoration,
  readVerticalAlign,
} from '../textElementLayout'
import { normalizeLabelEditorFontFamily, LABEL_EDITOR_FONT_FAMILY } from '../labelEditorFonts'

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 }
}

function readNativeSnapshotText (config: Record<string, any>): string {
  for (const key of ['__previewFormatted', '__PreviewFormatted', 'text', 'Text'] as const) {
    const raw = String(config[key] ?? '').trim()
    if (!raw) continue
    if (parseStoredPrintInputOffset(raw)) continue
    if (isCorruptedDateDisplayFormat(raw)) continue
    return raw
  }
  return ''
}

/**
 * 将 WEIGHT / DATE / TIME / DURATION 转为 TEXT_STATIC(展示文案与合并后的 config.text 一致),
 * LOGO → IMAGE,使同一套模板可走 native printTemplate,避免仅因元素类型名而整页光栅(进度长期停在 ~12–14%)。
 */
export function normalizeTemplateForNativeFastJob (
  template: SystemLabelTemplate,
  data: LabelTemplateData,
  baseTime: Date = new Date(),
): SystemLabelTemplate {
  const now = baseTime

  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 = ''
      if (type === 'DATE' || type === 'TIME' || type === 'DURATION') {
        const snap = readNativeSnapshotText(config)
        if (snap) {
          text = applyTemplateData(snap, data)
        } else {
          const live = resolveElementDateTimeDisplay(el, now)
          if (live != null && live.trim()) {
            text = applyTemplateData(live, data)
          }
        }
      } else if (type === 'WEIGHT') {
        let raw = String(config.text ?? config.Text ?? '').trim()
        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
      }
      const nextConfig: Record<string, unknown> = { ...config, text, nativeSourceType: type }
      if (readInvertColors(config)) {
        nextConfig.forceRasterText = true
      }
      return {
        ...el,
        type: 'TEXT_STATIC' as typeof el.type,
        config: nextConfig,
      }
    }
    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 withInvertRaster = elements.map((el) => {
    const cfg = { ...(el.config || {}) } as Record<string, unknown>
    if (readInvertColors(cfg)) {
      cfg.forceRasterText = true
    }
    return { ...el, config: cfg }
  })
  const merged = resolveNativeLayoutCollisions([...withInvertRaster, ...extras])
  return { ...template, elements: merged }
}

/** 存在任一原生不支持的元素时,预览打印应走光栅,避免「成功但缺内容/不出纸」与画布不一致 */
export function templateHasUnsupportedNativeFastElements (template: SystemLabelTemplate): boolean {
  for (const el of template.elements || []) {
    if (!isElementHandledByNativeFastPrinter(el)) return true
  }
  return false
}

/** DATE/TIME/DURATION 在原生 printTemplate 中易丢 config.text,须与预览同源走 Canvas 光栅 */
export function templateHasDateTimeElements (template: SystemLabelTemplate): boolean {
  for (const el of template.elements || []) {
    const type = String(el.type || '').toUpperCase()
    if (type === 'DATE' || type === 'TIME' || type === 'DURATION') return true
  }
  return false
}

/** 元素边框 / 黑底白字 / 斜体下划线 / 自定义字体 / 垂直对齐 / 竖排 / 横打等,原生路径无法完整还原时须走 canvas 光栅 */
export function templateRequiresCanvasStyleFidelity (template: SystemLabelTemplate): boolean {
  const paperBorder = String(template.border ?? (template as any).Border ?? '').trim().toLowerCase()
  if (paperBorder === 'line' || paperBorder === 'dotted' || paperBorder === 'solid') {
    return true
  }
  if (normalizeTemplatePrintOrientation(template.printOrientation) === 'horizontal') {
    return true
  }

  for (const el of template.elements || []) {
    const border = readElementBorder(el)
    const type = String(el.type || '').toUpperCase()
    if (border === 'line' || border === 'dotted' || border === 'solid') {
      return true
    }
    if (readElementRotation(el) !== 'horizontal') {
      return true
    }
    const cfg = (el.config || {}) as Record<string, unknown>
    if (readInvertColors(cfg)) {
      return true
    }
    if (readFontStyle(cfg) === 'italic') {
      return true
    }
    if (readTextDecoration(cfg) === 'underline') {
      return true
    }
    if (readFontWeight(cfg) === 'bold') {
      return true
    }
    if (readVerticalAlign(cfg) !== 'top') {
      return true
    }
    const normalizedFont = normalizeLabelEditorFontFamily(cfg.fontFamily ?? cfg.FontFamily)
    if (normalizedFont && normalizedFont !== LABEL_EDITOR_FONT_FAMILY) {
      return true
    }
    /** 营养表使用独立排版,原生与 canvas 字体/间距易不一致 */
    if (type === 'NUTRITION') {
      return true
    }
  }
  return false
}