imageRaster.ts 5.55 KB
import type { MonochromeImageData, PrintImageOptions, PrinterDriver, RawImageDataSource } from './types/printer'

const DEFAULT_IMAGE_THRESHOLD = 180

function yieldToUi (): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve, 0)
  })
}

function ensureMultipleOf8 (value: number, maxValue?: number): number {
  const safeMax = maxValue && maxValue > 0 ? Math.max(8, Math.floor(maxValue)) : 0
  let width = Math.max(8, Math.round(value || 0))
  if (safeMax && width > safeMax) width = safeMax
  if (width % 8 !== 0) {
    width += 8 - (width % 8)
    if (safeMax && width > safeMax) {
      width = safeMax - (safeMax % 8)
      if (width <= 0) width = 8
    }
  }
  return width
}

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 getDefaultMaxWidthDots (driver: PrinterDriver): number {
  if (driver.imageMaxWidthDots && driver.imageMaxWidthDots > 0) return driver.imageMaxWidthDots
  return driver.protocol === 'esc' ? 384 : 800
}

export function rasterizeImageData (
  source: RawImageDataSource,
  options: PrintImageOptions = {}
): MonochromeImageData {
  const sourceWidth = Math.max(1, Math.round(source.width || 0))
  const sourceHeight = Math.max(1, Math.round(source.height || 0))
  const targetWidth = ensureMultipleOf8(sourceWidth)
  const threshold = options.threshold != null ? Number(options.threshold) : DEFAULT_IMAGE_THRESHOLD
  const pixels: number[] = new Array(targetWidth * sourceHeight).fill(0)

  for (let y = 0; y < sourceHeight; y++) {
    for (let x = 0; x < sourceWidth; x++) {
      const index = (y * sourceWidth + x) * 4
      const alpha = Number(source.data[index + 3] ?? 255)
      const red = Number(source.data[index] ?? 255)
      const green = Number(source.data[index + 1] ?? 255)
      const blue = Number(source.data[index + 2] ?? 255)
      const gray = red * 0.299 + green * 0.587 + blue * 0.114
      pixels[y * targetWidth + x] = alpha <= 10 || gray > threshold ? 0 : 1
    }
  }

  return {
    width: targetWidth,
    height: sourceHeight,
    pixels,
  }
}

export async function rasterizeImageForPrinter (
  source: string,
  driver: PrinterDriver,
  options: PrintImageOptions = {}
): Promise<MonochromeImageData> {
  // #ifdef APP-PLUS
  const rawSource = String(source || '').trim()
  if (!rawSource) {
    throw new Error('IMAGE_SOURCE_EMPTY')
  }

  const BitmapFactory = plus.android.importClass('android.graphics.BitmapFactory')
  const Bitmap = plus.android.importClass('android.graphics.Bitmap')
  const Base64 = plus.android.importClass('android.util.Base64')

  const base64Payload = normalizeBase64Payload(rawSource)
  let sourceBitmap: any = null

  if (base64Payload) {
    const bytes = Base64.decode(base64Payload, 0)
    sourceBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length)
  } else {
    const localPath = resolveLocalImagePath(rawSource)
    sourceBitmap = BitmapFactory.decodeFile(localPath)
  }

  if (!sourceBitmap) {
    throw new Error('IMAGE_DECODE_FAILED')
  }

  const sourceWidth = Number(sourceBitmap.getWidth ? sourceBitmap.getWidth() : 0)
  const sourceHeight = Number(sourceBitmap.getHeight ? sourceBitmap.getHeight() : 0)
  if (!sourceWidth || !sourceHeight) {
    try {
      sourceBitmap.recycle && sourceBitmap.recycle()
    } catch (_) {}
    throw new Error('IMAGE_SIZE_INVALID')
  }

  const protocolMaxWidth = getDefaultMaxWidthDots(driver)
  const maxWidthDots = options.maxWidthDots && options.maxWidthDots > 0 ? options.maxWidthDots : protocolMaxWidth
  const targetWidth = ensureMultipleOf8(options.targetWidthDots || sourceWidth, maxWidthDots)
  const aspectRatio = sourceHeight / sourceWidth
  const targetHeight = Math.max(
    1,
    Math.round(options.targetHeightDots || (targetWidth * aspectRatio))
  )
  const threshold = options.threshold != null ? Number(options.threshold) : DEFAULT_IMAGE_THRESHOLD

  const scaledBitmap = Bitmap.createScaledBitmap(sourceBitmap, targetWidth, targetHeight, true)
  const rasterPixels: number[] = new Array(targetWidth * targetHeight)

  for (let y = 0; y < targetHeight; y++) {
    if (y > 0 && y % 16 === 0) {
      await yieldToUi()
    }
    for (let x = 0; x < targetWidth; x++) {
      const color = Number(scaledBitmap.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
      rasterPixels[y * targetWidth + x] = alpha <= 10 || gray > threshold ? 0 : 1
    }
  }

  try {
    if (scaledBitmap !== sourceBitmap && sourceBitmap?.recycle) sourceBitmap.recycle()
  } catch (_) {}
  try {
    scaledBitmap?.recycle && scaledBitmap.recycle()
  } catch (_) {}

  return {
    width: targetWidth,
    height: targetHeight,
    pixels: rasterPixels,
  }
  // #endif

  // #ifndef APP-PLUS
  throw new Error('IMAGE_PRINT_APP_ONLY')
  // #endif
}