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') const pushLongWordByChars = (word: string) => { let buf = '' for (let i = 0; i < word.length; i++) { const ch = word.charAt(i) const trial = buf + ch if (buf && Number(paint.measureText(trial)) > maxWidth) { lines.push(buf) buf = ch } else { buf = trial } } return buf } rawLines.forEach((segment) => { if (!segment) { lines.push('') return } if (!segment.trim()) { lines.push(segment) return } const words = segment.trim().split(/\s+/) let current = '' for (const word of words) { const trial = current ? `${current} ${word}` : word if (Number(paint.measureText(trial)) <= maxWidth) { current = trial } else { if (current) lines.push(current) if (Number(paint.measureText(word)) <= maxWidth) { current = word } else { current = pushLongWordByChars(word) } } } if (current) 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 /** * TEXT_PRICE:d320fax 等走 TSC 内置字体时常丢整行(¥、小数点、数字组合)。 * 只要非空一律走位图,与画布一致;App 端 createTextBitmapPatch 失败时再回退 TSC。 */ if (normalizedType === 'TEXT_PRICE') 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) { 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, } }