nativeBitmapPatch.ts 10.4 KB
import type {
  MonochromeImageData,
  SystemTemplateElementBase,
  SystemTemplateTextAlign,
} from './types/printer'

declare const plus: any

const DESIGN_DPI = 96
const DEFAULT_THRESHOLD = 180
const TEXT_PADDING_DOTS = 6

function normalizePrinterLikeText (str: string): string {
  return String(str || '')
    .normalize('NFKC')
    .replace(/[\u2018\u2019]/g, '\'')
    .replace(/[\u201C\u201D]/g, '"')
    .replace(/[\u2013\u2014]/g, '-')
}

type BitmapPatchItem = {
  type: 'bitmap'
  x: number
  y: number
  image: MonochromeImageData
}

function clamp (value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, Math.round(value)))
}

function ensureMultipleOf8 (value: number): number {
  const safe = Math.max(8, Math.round(value || 0))
  return safe % 8 === 0 ? safe : safe + (8 - (safe % 8))
}

function pxToDots (value: number, dpi: number): number {
  return Math.max(0, Math.round((Number(value) || 0) * dpi / DESIGN_DPI))
}

function normalizeBase64Payload (source: string): string {
  const value = String(source || '').trim()
  if (!value) return ''
  if (value.startsWith('data:image/')) {
    const index = value.indexOf(',')
    return index >= 0 ? value.slice(index + 1) : ''
  }
  if (/^[A-Za-z0-9+/=\r\n]+$/.test(value) && value.length > 128) {
    return value.replace(/\s+/g, '')
  }
  return ''
}

function resolveLocalImagePath (source: string): string {
  let path = String(source || '').trim()
  if (!path) return ''
  if (path.startsWith('file://')) {
    path = path.replace(/^file:\/\//, '')
  }
  // #ifdef APP-PLUS
  try {
    const converted = plus.io.convertLocalFileSystemURL(path)
    if (converted) path = converted
  } catch (_) {}
  // #endif
  try {
    path = decodeURIComponent(path)
  } catch (_) {}
  return path
}

function getAndroidGraphics () {
  // #ifdef APP-PLUS
  try {
    if (typeof plus === 'undefined' || String(plus.os?.name || '').toLowerCase() !== 'android') return null
    return {
      Bitmap: plus.android.importClass('android.graphics.Bitmap'),
      BitmapFactory: plus.android.importClass('android.graphics.BitmapFactory'),
      BitmapConfig: plus.android.importClass('android.graphics.Bitmap$Config'),
      Canvas: plus.android.importClass('android.graphics.Canvas'),
      Paint: plus.android.importClass('android.graphics.Paint'),
      Color: plus.android.importClass('android.graphics.Color'),
      Typeface: plus.android.importClass('android.graphics.Typeface'),
      Base64: plus.android.importClass('android.util.Base64'),
    }
  } catch (error) {
    console.error('getAndroidGraphics failed', error)
    return null
  }
  // #endif
  // #ifndef APP-PLUS
  return null
  // #endif
}

function bitmapToMonochromeImage (
  bitmap: any,
  threshold = DEFAULT_THRESHOLD
): MonochromeImageData {
  const bitmapWidth = Number(bitmap.getWidth ? bitmap.getWidth() : 0)
  const bitmapHeight = Number(bitmap.getHeight ? bitmap.getHeight() : 0)
  const width = ensureMultipleOf8(bitmapWidth)
  const height = Math.max(1, bitmapHeight)
  const pixels: number[] = new Array(width * height).fill(0)

  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      if (x >= bitmapWidth) {
        pixels[y * width + x] = 0
        continue
      }
      const color = Number(bitmap.getPixel(x, y))
      const alpha = (color >>> 24) & 0xff
      const red = (color >>> 16) & 0xff
      const green = (color >>> 8) & 0xff
      const blue = color & 0xff
      const gray = red * 0.299 + green * 0.587 + blue * 0.114
      pixels[y * width + x] = alpha <= 10 || gray > threshold ? 0 : 1
    }
  }

  return { width, height, pixels }
}

function splitTextLines (text: string, paint: any, maxWidth: number): string[] {
  const lines: string[] = []
  const rawLines = String(text || '').replace(/\r/g, '').split('\n')

  rawLines.forEach((segment) => {
    if (!segment) {
      lines.push('')
      return
    }

    let current = ''
    for (let i = 0; i < segment.length; i++) {
      const char = segment.charAt(i)
      const candidate = current + char
      const measure = Number(paint.measureText(candidate))
      if (current && measure > maxWidth) {
        lines.push(current)
        current = char
      } else {
        current = candidate
      }
    }
    if (current || lines.length === 0) lines.push(current)
  })

  return lines.length > 0 ? lines : ['']
}

export function shouldRasterizeTextElement (text: string, type: string): boolean {
  const normalizedType = String(type || '').toUpperCase()
  const normalizedText = normalizePrinterLikeText(text)
  if (!normalizedText) return false
  if (normalizedType === 'TEXT_PRICE' && /[€£¥¥]/.test(normalizedText)) return true
  if (/[€£¥¥éÉáàâäãåæçèêëìíîïñòóôöõøùúûüýÿœšž]/.test(normalizedText)) return true
  return /[^\x20-\x7E]/.test(normalizedText)
}

export function createTextBitmapPatch (params: {
  element: SystemTemplateElementBase
  text: string
  dpi: number
  align: SystemTemplateTextAlign
}): BitmapPatchItem | null {
  const graphics = getAndroidGraphics()
  if (!graphics) return null

  const { element, text, dpi, align } = params
  const config = element.config || {}
  const Bitmap = graphics.Bitmap
  const BitmapConfig = graphics.BitmapConfig
  const Canvas = graphics.Canvas
  const Paint = graphics.Paint
  const Color = graphics.Color
  const Typeface = graphics.Typeface

  const contentWidth = Math.max(8, pxToDots(element.width, dpi))
  const width = ensureMultipleOf8(contentWidth + TEXT_PADDING_DOTS * 2)
  const height = Math.max(16, pxToDots(element.height, dpi) + TEXT_PADDING_DOTS * 2)
  const bitmap = Bitmap.createBitmap(width, height, BitmapConfig.ARGB_8888)
  const canvas = new Canvas(bitmap)
  canvas.drawColor(Color.WHITE)

  const paint = new Paint()
  paint.setAntiAlias(true)
  paint.setDither(true)
  paint.setColor(Color.BLACK)
  paint.setSubpixelText(true)
  const fontSizeDots = Math.max(14, pxToDots(Number(config.fontSize || 14), dpi))
  paint.setTextSize(fontSizeDots)
  const isBold = String(config.fontWeight || '').toLowerCase() === 'bold' || String(element.type || '').toUpperCase() === 'TEXT_PRICE'
  paint.setFakeBoldText(isBold)
  paint.setTypeface(isBold ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT)

  const maxTextWidth = Math.max(8, contentWidth)
  const lines = splitTextLines(text, paint, maxTextWidth)
  const fontMetrics = paint.getFontMetrics()
  const lineHeight = Math.max(
    fontSizeDots + 2,
    Math.ceil(Math.abs(Number(fontMetrics.top)) + Math.abs(Number(fontMetrics.bottom)) + 2)
  )
  const totalHeight = lines.length * lineHeight
  const isCenteredVertically = String(element.type || '').toUpperCase() === 'TEXT_PRICE'
  const topOffset = isCenteredVertically
    ? Math.max(TEXT_PADDING_DOTS, Math.floor((height - totalHeight) / 2))
    : TEXT_PADDING_DOTS

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]
    const lineWidth = Number(paint.measureText(line))
    let drawX = TEXT_PADDING_DOTS
    if (align === 'center') {
      drawX = TEXT_PADDING_DOTS + Math.max(0, Math.round((maxTextWidth - lineWidth) / 2))
    } else if (align === 'right') {
      drawX = TEXT_PADDING_DOTS + Math.max(0, Math.round(maxTextWidth - lineWidth))
    }
    const baseline = topOffset + i * lineHeight - Number(fontMetrics.top)
    canvas.drawText(line, drawX, baseline, paint)
  }

  const image = bitmapToMonochromeImage(bitmap)
  try {
    bitmap.recycle && bitmap.recycle()
  } catch (_) {}

  return {
    type: 'bitmap',
    x: Math.max(0, pxToDots(element.x, dpi) - TEXT_PADDING_DOTS),
    y: Math.max(0, pxToDots(element.y, dpi) - TEXT_PADDING_DOTS),
    image,
  }
}

function decodeSourceBitmap (source: string, graphics: ReturnType<typeof getAndroidGraphics>) {
  if (!graphics) return null
  const rawSource = String(source || '').trim()
  if (!rawSource) return null

  const base64Payload = normalizeBase64Payload(rawSource)
  if (base64Payload) {
    const bytes = graphics.Base64.decode(base64Payload, 0)
    return graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.length)
  }

  const localPath = resolveLocalImagePath(rawSource)
  if (!localPath) return null
  return graphics.BitmapFactory.decodeFile(localPath)
}

export function createImageBitmapPatch (params: {
  element: SystemTemplateElementBase
  dpi: number
}): BitmapPatchItem | null {
  const graphics = getAndroidGraphics()
  if (!graphics) return null

  const { element, dpi } = params
  const config = element.config || {}
  const sourceBitmap = decodeSourceBitmap(String(config.src || config.data || config.url || ''), graphics)
  if (!sourceBitmap) return null

  const Bitmap = graphics.Bitmap
  const BitmapConfig = graphics.BitmapConfig
  const Canvas = graphics.Canvas
  const Paint = graphics.Paint
  const Color = graphics.Color

  const width = ensureMultipleOf8(Math.max(8, pxToDots(element.width, dpi)))
  const height = Math.max(8, pxToDots(element.height, dpi))
  const outputBitmap = Bitmap.createBitmap(width, height, BitmapConfig.ARGB_8888)
  const canvas = new Canvas(outputBitmap)
  canvas.drawColor(Color.WHITE)

  const sourceWidth = Number(sourceBitmap.getWidth ? sourceBitmap.getWidth() : 0)
  const sourceHeight = Number(sourceBitmap.getHeight ? sourceBitmap.getHeight() : 0)
  const scaleMode = String(config.scaleMode || 'contain').toLowerCase()

  let targetWidth = width
  let targetHeight = height
  let targetLeft = 0
  let targetTop = 0

  if (sourceWidth > 0 && sourceHeight > 0 && scaleMode !== 'fill') {
    const ratio = scaleMode === 'cover'
      ? Math.max(width / sourceWidth, height / sourceHeight)
      : Math.min(width / sourceWidth, height / sourceHeight)
    targetWidth = Math.max(1, Math.round(sourceWidth * ratio))
    targetHeight = Math.max(1, Math.round(sourceHeight * ratio))
    targetLeft = Math.round((width - targetWidth) / 2)
    targetTop = Math.round((height - targetHeight) / 2)
  }

  const scaledBitmap = Bitmap.createScaledBitmap(sourceBitmap, targetWidth, targetHeight, true)
  const paint = new Paint()
  paint.setAntiAlias(true)
  paint.setFilterBitmap(true)
  canvas.drawBitmap(scaledBitmap, targetLeft, targetTop, paint)

  const image = bitmapToMonochromeImage(outputBitmap, Number(config.threshold || DEFAULT_THRESHOLD))

  try {
    scaledBitmap?.recycle && scaledBitmap.recycle()
  } catch (_) {}
  try {
    sourceBitmap?.recycle && sourceBitmap.recycle()
  } catch (_) {}
  try {
    outputBitmap?.recycle && outputBitmap.recycle()
  } catch (_) {}

  return {
    type: 'bitmap',
    x: pxToDots(element.x, dpi),
    y: pxToDots(element.y, dpi),
    image,
  }
}