import { normalizeBarcodeType } from '../barcodeFormat' import { storedValueLooksLikeImagePath } from '../resolveMediaUrl' import { createImageBitmapPatch, createTextBitmapPatch, shouldRasterizeTextElement, } from './nativeBitmapPatch' import { applyTemplateData } from './templateRenderer' import type { EscTemplateItem, LabelTemplateData, PrinterTemplateUnit, StructuredLabelTemplate, TscTemplateItem, StructuredTscTemplate, StructuredEscTemplate, SystemLabelTemplate, SystemTemplateElementBase, SystemTemplateTextAlign, } from './types/printer' const DESIGN_DPI = 96 function roundNumber (value: number, digits = 1): number { const factor = Math.pow(10, digits) return Math.round(value * factor) / factor } function toMillimeter (value: number, unit: PrinterTemplateUnit = 'inch'): number { if (unit === 'mm') return value if (unit === 'cm') return value * 10 if (unit === 'px') return value / DESIGN_DPI * 25.4 return value * 25.4 } function templateWidthPx (template: SystemLabelTemplate): number { return toMillimeter(template.width, template.unit || 'inch') / 25.4 * DESIGN_DPI } function pxToDots (value: number, dpi: number): number { return Math.max(0, Math.round((Number(value) || 0) * dpi / DESIGN_DPI)) } function clamp (value: number, min: number, max: number): number { return Math.max(min, Math.min(max, Math.round(value))) } function sortElements (elements: SystemTemplateElementBase[]): SystemTemplateElementBase[] { return [...elements].sort((a, b) => { if (a.y !== b.y) return a.y - b.y return a.x - b.x }) } function getConfigString ( config: Record, keys: string[], fallback = '' ): string { for (let i = 0; i < keys.length; i++) { const value = config?.[keys[i]] if (value != null && value !== '') return String(value) } return fallback } function getConfigNumber ( config: Record, keys: string[], fallback = 0 ): number { for (let i = 0; i < keys.length; i++) { const value = Number(config?.[keys[i]]) if (!Number.isNaN(value) && Number.isFinite(value)) return value } return fallback } function toCamelCaseKey (value: string): string { return value .toLowerCase() .split(/[_\s-]+/) .map((segment, index) => index === 0 ? segment : segment.charAt(0).toUpperCase() + segment.slice(1)) .join('') } function resolveBindingKey (element: SystemTemplateElementBase): string { const config = element.config || {} const explicit = getConfigString(config, ['dataKey', 'field', 'bindField', 'key', 'valueKey']) if (explicit) return explicit const type = String(element.type || '').toUpperCase() const map: Record = { TEXT_PRODUCT: 'productName', TEXT_LABEL_ID: 'labelId', TEXT_CATEGORY: 'category', TEXT_PRICE: 'price', TEXT_DATE: 'date', TEXT_TIME: 'time', QRCODE: 'qrCode', BARCODE: 'barcode', } if (map[type]) return map[type] const pureType = type .replace(/^TEXT_/, '') .replace(/^FIELD_/, '') .replace(/^VALUE_/, '') return pureType ? toCamelCaseKey(pureType) : '' } function resolveTemplateFieldValue (data: LabelTemplateData, key: string): string { if (!key) return '' const candidates = [key] if (key === 'productName') candidates.push('product') if (key === 'product') candidates.push('productName') if (key === 'qrCode') candidates.push('labelId', 'barcode') if (key === 'barcode') candidates.push('labelId', 'qrCode') for (let i = 0; i < candidates.length; i++) { const value = data[candidates[i]] if (value != null) return String(value) } return '' } function formatPriceValue ( rawValue: string, config: Record ): string { const prefix = getConfigString(config, ['prefix', 'Prefix'], '') const suffix = getConfigString(config, ['suffix', 'Suffix'], '') const decimal = getConfigNumber(config, ['decimal', 'Decimal'], -1) const numericValue = Number(rawValue) const value = !Number.isNaN(numericValue) && Number.isFinite(numericValue) && decimal >= 0 ? numericValue.toFixed(decimal) : rawValue return `${prefix}${value}${suffix}` } /** WEIGHT / DATE / TIME / DURATION:画布已把展示写入 config.text;此处兜底 value+unit、format */ function resolvePlainTextLikeElement ( element: SystemTemplateElementBase, data: LabelTemplateData ): string { const config = element.config || {} const t = getConfigString(config, ['text', 'Text']) if (t) return applyTemplateData(t, data) const type = String(element.type || '').toUpperCase() if (type === 'WEIGHT') { const v = getConfigString(config, ['value', 'Value']) const u = getConfigString(config, ['unit', 'Unit']) if (!v && !u) return '' if (v && u && !v.endsWith(u)) return `${v}${u}` return v || u } /** 与预览一致:展示用文案在 config.text;无 text 时不应把 format 模板(如 YYYY-MM-DD)当内容打印 */ if (type === 'DATE' || type === 'TIME' || type === 'DURATION') { return '' } return '' } function resolveElementText ( element: SystemTemplateElementBase, data: LabelTemplateData ): string { const config = element.config || {} const type = String(element.type || '').toUpperCase() const hasText = config.text != null && config.text !== '' const vst = String(element.valueSourceType || '').toUpperCase() if (type === 'TEXT_PRICE') { const bindingKey = resolveBindingKey(element) const boundValue = resolveTemplateFieldValue(data, bindingKey) const rawCfg = getConfigString(config, [ 'text', 'Text', 'value', 'Value', 'displayText', 'DisplayText', 'displayValue', 'DisplayValue', 'defaultValue', 'DefaultValue', ]) /** FIXED:重打快照里价格已在 config.text,勿用空 data 绑定出 0 */ if (vst === 'FIXED' && rawCfg.trim()) { return formatPriceValue(rawCfg, config) } const baseValue = boundValue || (rawCfg ? applyTemplateData(rawCfg, data) : '') return baseValue.trim() ? formatPriceValue(baseValue, config) : '' } /** FIXED:TEXT_PRODUCT 等勿在 data 为空时仍走 productName 绑定(与快照 config.text 冲突) */ if ( vst === 'FIXED' && hasText && (type === 'TEXT_PRODUCT' || type === 'TEXT_CATEGORY' || type === 'TEXT_LABEL_ID') ) { return applyTemplateData(String(config.text), data) } if (hasText && type === 'TEXT_STATIC') { return applyTemplateData(String(config.text), data) } if (hasText && String(config.text).includes('{{')) { return applyTemplateData(String(config.text), data) } const bindingKey = resolveBindingKey(element) const boundValue = resolveTemplateFieldValue(data, bindingKey) if (boundValue) return boundValue if (hasText) return applyTemplateData(String(config.text), data) return '' } function resolveElementDataValue ( element: SystemTemplateElementBase, data: LabelTemplateData ): string { const config = element.config || {} const raw = getConfigString(config, ['data', 'Data', 'value', 'Value', 'src', 'Src', 'url', 'Url']) if (raw) return applyTemplateData(raw, data) return resolveTemplateFieldValue(data, resolveBindingKey(element)) } function resolveElementAlign ( element: SystemTemplateElementBase, pageWidthPx: number ): SystemTemplateTextAlign { const config = element.config || {} const align = String(config.textAlign || '').toLowerCase() if (align === 'left' || align === 'center' || align === 'right') return align as SystemTemplateTextAlign const centerX = (Number(element.x) || 0) + (Number(element.width) || 0) / 2 if (centerX <= pageWidthPx * 0.33) return 'left' if (centerX >= pageWidthPx * 0.67) return 'right' return 'center' } function toEscAlign (align: SystemTemplateTextAlign): 0 | 1 | 2 { if (align === 'center') return 1 if (align === 'right') return 2 return 0 } function resolveRotation (value?: string): number { return value === 'vertical' ? 90 : 0 } function normalizeQrLevel (value?: string): 'L' | 'M' | 'Q' | 'H' { const key = String(value || 'M').trim().toUpperCase() if (key === 'L' || key === 'M' || key === 'Q' || key === 'H') return key return 'M' } function estimateTextWidthDots (text: string, fontDots: number): number { let total = 0 for (let i = 0; i < text.length; i++) { const code = text.charCodeAt(i) total += code > 255 ? fontDots : fontDots * 0.6 } return Math.round(total) } function resolveTextScale (fontSizePx: number, dpi: number): number { const targetDots = Math.max(12, Math.round(fontSizePx * dpi / DESIGN_DPI)) return clamp(targetDots / 24, 1, 7) } function estimateQrModuleCount (value: string, level: 'L' | 'M' | 'Q' | 'H'): number { const capacities: Record<'L' | 'M' | 'Q' | 'H', number[]> = { L: [17, 32, 53, 78, 106, 134, 154, 192, 230, 271], M: [14, 26, 42, 62, 84, 106, 122, 152, 180, 213], Q: [11, 20, 32, 46, 60, 74, 86, 108, 130, 151], H: [7, 14, 24, 34, 44, 58, 64, 84, 98, 119], } const length = Math.max(1, String(value || '').length) const versions = capacities[level] || capacities.M let version = versions.length for (let i = 0; i < versions.length; i++) { if (length <= versions[i]) { version = i + 1 break } } return 21 + (version - 1) * 4 } function resolveQrModuleSize ( widthPx: number, heightPx: number, dpi: number, value: string, level: 'L' | 'M' | 'Q' | 'H' ): number { const targetDots = Math.max(24, Math.min( pxToDots(widthPx, dpi), pxToDots(heightPx, dpi) )) const moduleCount = Math.max(21, estimateQrModuleCount(value, level)) return clamp(Math.floor(targetDots / moduleCount), 3, 12) } function resolveTextX (params: { align: SystemTemplateTextAlign xPx: number widthPx: number dpi: number text: string scale: number }): number { const left = pxToDots(params.xPx, params.dpi) if (params.align === 'left') return left const boxWidth = pxToDots(params.widthPx, params.dpi) const fontDots = Math.max(24, params.scale * 24) const textWidth = estimateTextWidthDots(params.text, fontDots) if (params.align === 'center') { return Math.max(0, left + Math.round(Math.max(0, boxWidth - textWidth) / 2)) } return Math.max(0, left + Math.max(0, boxWidth - textWidth)) } /** 全角人民币符在 TSC 内置字库常成「?」;规范为半角 ¥(U+00A5),与 tscLabelBuilder 单字节编码一致,勿再用字母 Y */ function sanitizeTextForTscBuiltinFont (text: string): string { return String(text || '') .replace(/\uFFE5/g, '\u00A5') .replace(/¥/g, '\u00A5') } /** 估算 TSC 项底部点坐标,用于避免 SIZE 高度略小于内容时裁掉最后一行(常见于底部价格) */ function pushElementBorderBoxIfNeeded ( items: TscTemplateItem[], element: SystemTemplateElementBase, dpi: number ) { const type = String(element.type || '').toUpperCase() if (type === 'BLANK') return const border = String(element.border || '').toLowerCase() if (border !== 'line' && border !== 'dotted') return items.push({ type: 'box', x: pxToDots(element.x, dpi), y: pxToDots(element.y, dpi), width: Math.max(1, pxToDots(element.width, dpi)), height: Math.max(1, pxToDots(element.height, dpi)), lineWidth: border === 'dotted' ? 1 : 2, }) } function estimateTscItemBottomDots (item: TscTemplateItem): number { switch (item.type) { case 'bitmap': return item.y + item.image.height case 'text': { const scale = item.yScale || 1 return item.y + Math.round(24 * scale * 2) } case 'qrcode': { const cw = item.cellWidth || 4 return item.y + cw * 72 } case 'barcode': return item.y + Math.max(20, Math.round(item.height || 80)) case 'bar': return item.y + item.height case 'box': return item.y + item.height default: return 0 } } function buildTscTemplate ( template: SystemLabelTemplate, data: LabelTemplateData, dpi: number, printQty: number, options: { disableBitmapText?: boolean allowCurrencyBitmapWhenDisabled?: boolean } = {} ): StructuredTscTemplate { const widthMm = roundNumber(toMillimeter(template.width, template.unit || 'inch')) let heightMm = roundNumber(toMillimeter(template.height, template.unit || 'inch')) const items: TscTemplateItem[] = [] const pageWidth = templateWidthPx(template) sortElements(template.elements).forEach((element) => { const config = element.config || {} const type = String(element.type || '').toUpperCase() pushElementBorderBoxIfNeeded(items, element, dpi) const renderAsTextBlock = type.startsWith('TEXT_') || type === 'WEIGHT' || type === 'DATE' || type === 'TIME' || type === 'DURATION' if (renderAsTextBlock) { const text = type.startsWith('TEXT_') ? resolveElementText(element, data) : resolvePlainTextLikeElement(element, data) if (!text) return const scale = resolveTextScale(getConfigNumber(config, ['fontSize'], 14), dpi) const align = resolveElementAlign(element, pageWidth) /** * gp-d320fx / d320fax 等 disableBitmapText 时:内置字库对 ¥ 与 TEXT_PRICE 整行常异常。 * 在 allowCurrencyBitmapWhenDisabled 下对 TEXT_PRICE 一律尝试位图(不仅限于含货币符),与预览一致。 */ const currencyGlyph = /[\u00A5\uFFE5€£¥]/.test(text) const allowBitmapWhenDisabled = options.allowCurrencyBitmapWhenDisabled !== false && (currencyGlyph || type === 'TEXT_PRICE') const tryTextBitmap = shouldRasterizeTextElement(text, type) && (!options.disableBitmapText || allowBitmapWhenDisabled) if (tryTextBitmap) { const bitmapPatch = createTextBitmapPatch({ element, text, dpi, align, }) if (bitmapPatch) { items.push(bitmapPatch) return } } const textForTsc = sanitizeTextForTscBuiltinFont(text) items.push({ type: 'text', x: resolveTextX({ align, xPx: element.x, widthPx: element.width, dpi, text: textForTsc, scale, }), y: pxToDots(element.y, dpi), text: textForTsc, font: 'TSS24.BF2', rotation: resolveRotation(element.rotation), xScale: scale, yScale: scale, }) return } if (type === 'QRCODE') { const value = resolveElementDataValue(element, data) if (!value) return if (storedValueLooksLikeImagePath(value)) { const bitmapPatch = createImageBitmapPatch({ element: { ...element, config: { ...config, src: value, url: value, Src: value, Url: value }, }, dpi, }) if (bitmapPatch) items.push(bitmapPatch) return } const level = normalizeQrLevel(getConfigString(config, ['errorLevel'], 'M')) items.push({ type: 'qrcode', x: pxToDots(element.x, dpi), y: pxToDots(element.y, dpi), value, level, cellWidth: resolveQrModuleSize(element.width, element.height, dpi, value, level), mode: 'A', }) return } if (type === 'BARCODE') { const value = resolveElementDataValue(element, data) if (!value) return const symbology = normalizeBarcodeType(getConfigString(config, ['barcodeType'], '')) const rotation = resolveRotation( element.rotation || getConfigString(config, ['orientation'], 'horizontal') ) items.push({ type: 'barcode', x: pxToDots(element.x, dpi), y: pxToDots(element.y, dpi), value, symbology, height: Math.max(20, pxToDots(element.height, dpi)), readable: config.showText !== false, rotation, narrow: clamp(element.width / Math.max(40, value.length * 6), 1, 4), wide: clamp(element.width / Math.max(24, value.length * 3), 2, 6), }) return } if (type === 'IMAGE') { let bitmapPatch = createImageBitmapPatch({ element, dpi, }) if (!bitmapPatch) { const boundImage = resolveElementDataValue(element, data) if (storedValueLooksLikeImagePath(boundImage)) { bitmapPatch = createImageBitmapPatch({ element: { ...element, config: { ...config, src: boundImage, url: boundImage, Src: boundImage, Url: boundImage }, }, dpi, }) } } if (bitmapPatch) items.push(bitmapPatch) return } if (type === 'BLANK' && String(element.border || '').toLowerCase() === 'line') { items.push({ type: 'bar', x: pxToDots(element.x, dpi), y: pxToDots(element.y, dpi), width: Math.max(1, pxToDots(element.width, dpi)), height: Math.max(1, pxToDots(element.height || 1, dpi)), }) } }) const maxBottomDots = items.reduce( (acc, it) => Math.max(acc, estimateTscItemBottomDots(it)), 0 ) const templateHeightDots = Math.max(1, Math.round((heightMm / 25.4) * dpi)) if (maxBottomDots > templateHeightDots - 2) { heightMm = roundNumber(Math.max(heightMm, (maxBottomDots / dpi) * 25.4 + 2)) } return { widthMm, heightMm, gapMm: 0, density: 14, speed: 5, printQty, items, } } function buildEscTemplate ( template: SystemLabelTemplate, data: LabelTemplateData, printQty: number ): StructuredEscTemplate { const pageWidth = templateWidthPx(template) const items: EscTemplateItem[] = [] sortElements(template.elements).forEach((element) => { const config = element.config || {} const type = String(element.type || '').toUpperCase() const align = toEscAlign(resolveElementAlign(element, pageWidth)) const renderAsTextBlockEsc = type.startsWith('TEXT_') || type === 'WEIGHT' || type === 'DATE' || type === 'TIME' || type === 'DURATION' if (renderAsTextBlockEsc) { const text = type.startsWith('TEXT_') ? resolveElementText(element, data) : resolvePlainTextLikeElement(element, data) if (!text) return const fontSize = getConfigNumber(config, ['fontSize'], 14) const scale = fontSize >= 28 ? 2 : 1 items.push({ type: 'text', text: sanitizeTextForTscBuiltinFont(text), align, bold: String(config.fontWeight || '').toLowerCase() === 'bold', widthScale: scale, heightScale: scale, }) return } if (type === 'QRCODE') { const value = resolveElementDataValue(element, data) if (!value) return const level = normalizeQrLevel(getConfigString(config, ['errorLevel'], 'M')) items.push({ type: 'qrcode', value, align, size: resolveQrModuleSize(element.width, element.height, 203, value, level), level, }) return } if (type === 'BARCODE') { const value = resolveElementDataValue(element, data) if (!value) return items.push({ type: 'barcode', value, align, symbology: normalizeBarcodeType(getConfigString(config, ['barcodeType'], '')), height: clamp(element.height * 2, 48, 180), width: clamp(element.width / Math.max(48, value.length * 4), 2, 6), showText: config.showText !== false, }) return } if (type === 'BLANK' && String(element.border || '').toLowerCase() === 'line') { items.push({ type: 'rule', width: clamp(element.width / 8, 8, 48), }) } }) return { printQty, feedLines: 3, items, } } export function adaptSystemLabelTemplate ( template: SystemLabelTemplate, data: LabelTemplateData = {}, options: { dpi?: number printQty?: number disableBitmapText?: boolean allowCurrencyBitmapWhenDisabled?: boolean } = {} ): StructuredLabelTemplate { const dpi = options.dpi || 203 const printQty = Math.max(1, Math.round(options.printQty || 1)) return { key: template.id || template.name || 'system-label-template', tsc: buildTscTemplate(template, data, dpi, printQty, { disableBitmapText: options.disableBitmapText, allowCurrencyBitmapWhenDisabled: options.allowCurrencyBitmapWhenDisabled, }), esc: buildEscTemplate(template, data, printQty), } }