Commit 9927b97ee7774f64e0bf75edecf2cc8d889aec0a
1 parent
961eecae
Improve GP_R3 printing flow and printer onboarding
Enhance TCP built-in fallback with port probing and update label/Bluetooth print templates. Made-with: Cursor
Showing
22 changed files
with
1931 additions
and
98 deletions
美国版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue
| ... | ... | @@ -17,8 +17,9 @@ |
| 17 | 17 | |
| 18 | 18 | <view class="content"> |
| 19 | 19 | <!-- Printer type: Bluetooth | Built-in --> |
| 20 | - <view class="type-tabs"> | |
| 20 | + <view v-if="availablePrinterTypes.length > 1" class="type-tabs"> | |
| 21 | 21 | <view |
| 22 | + v-if="isBluetoothModeAvailable" | |
| 22 | 23 | class="tab" |
| 23 | 24 | :class="{ active: printerType === 'bluetooth' }" |
| 24 | 25 | @click="switchType('bluetooth')" |
| ... | ... | @@ -27,6 +28,7 @@ |
| 27 | 28 | <text class="tab-text">Bluetooth</text> |
| 28 | 29 | </view> |
| 29 | 30 | <view |
| 31 | + v-if="isBuiltinModeAvailable" | |
| 30 | 32 | class="tab" |
| 31 | 33 | :class="{ active: printerType === 'builtin' }" |
| 32 | 34 | @click="switchType('builtin')" |
| ... | ... | @@ -45,6 +47,10 @@ |
| 45 | 47 | <view class="debug-card"> |
| 46 | 48 | <text class="debug-title">Debug Status</text> |
| 47 | 49 | <text class="debug-item">Current Mode: {{ debugInfo.currentMode }}</text> |
| 50 | + <text class="debug-item">Available Mode: {{ availablePrinterTypesLabel }}</text> | |
| 51 | + <text class="debug-item">Device Model: {{ deviceIdentity.model || '-' }}</text> | |
| 52 | + <text class="debug-item">Device Brand: {{ deviceIdentity.brand || '-' }}</text> | |
| 53 | + <text class="debug-item">Device Product: {{ deviceIdentity.product || '-' }}</text> | |
| 48 | 54 | <text class="debug-item">Classic Module: {{ debugInfo.classicModuleReady ? 'Ready' : 'Not Ready' }}</text> |
| 49 | 55 | <text class="debug-item">Paired Count: {{ debugInfo.pairedCount }}</text> |
| 50 | 56 | <text class="debug-item">Virtual BT Printer: {{ debugInfo.foundVirtualPrinter ? 'Found' : 'Not Found' }}</text> |
| ... | ... | @@ -60,7 +66,7 @@ |
| 60 | 66 | <view v-if="connectedDevice" class="connected-card"> |
| 61 | 67 | <view class="connected-header"> |
| 62 | 68 | <view class="connected-icon"> |
| 63 | - <AppIcon name="bluetooth" size="md" color="white" /> | |
| 69 | + <AppIcon :name="printerType === 'builtin' ? 'printer' : 'bluetooth'" size="md" color="white" /> | |
| 64 | 70 | </view> |
| 65 | 71 | <view class="connected-info"> |
| 66 | 72 | <text class="connected-name">{{ connectedDevice.name }}</text> |
| ... | ... | @@ -167,9 +173,11 @@ import AppIcon from '../../components/AppIcon.vue' |
| 167 | 173 | import SideMenu from '../../components/SideMenu.vue' |
| 168 | 174 | import LocationPicker from '../../components/LocationPicker.vue' |
| 169 | 175 | import { getStatusBarHeight } from '../../utils/statusBar' |
| 176 | +import { getDeviceIdentity } from '../../utils/deviceInfo' | |
| 170 | 177 | import classicBluetooth from '../../utils/print/bluetoothTool.js' |
| 171 | 178 | import { |
| 172 | 179 | getPrinterType, |
| 180 | + getAvailablePrinterTypes, | |
| 173 | 181 | clearPrinter, |
| 174 | 182 | type PrinterType, |
| 175 | 183 | } from '../../utils/print/printerConnection' |
| ... | ... | @@ -189,7 +197,14 @@ const isScanning = ref(false) |
| 189 | 197 | const connectingId = ref('') |
| 190 | 198 | const errorMsg = ref('') |
| 191 | 199 | const btAdapterReady = ref(false) |
| 192 | -const printerType = ref<PrinterType | ''>(getPrinterType() || 'bluetooth') | |
| 200 | +const availablePrinterTypes = getAvailablePrinterTypes() | |
| 201 | +const availablePrinterTypesLabel = availablePrinterTypes.map(item => item === 'builtin' ? 'Built-in' : 'Bluetooth').join(' / ') | |
| 202 | +const deviceIdentity = getDeviceIdentity() | |
| 203 | +const storedPrinterType = getPrinterType() | |
| 204 | +const preferredPrinterType = (storedPrinterType && availablePrinterTypes.includes(storedPrinterType as PrinterType)) | |
| 205 | + ? storedPrinterType as PrinterType | |
| 206 | + : availablePrinterTypes[0] | |
| 207 | +const printerType = ref<PrinterType | ''>(preferredPrinterType || 'bluetooth') | |
| 193 | 208 | const currentPrinter = ref(getCurrentPrinterSummary()) |
| 194 | 209 | const debugInfo = ref({ |
| 195 | 210 | currentMode: 'none', |
| ... | ... | @@ -211,6 +226,8 @@ interface BtDevice { |
| 211 | 226 | |
| 212 | 227 | const devices = ref<BtDevice[]>([]) |
| 213 | 228 | const discoveredIds = new Set<string>() |
| 229 | +const isBluetoothModeAvailable = availablePrinterTypes.includes('bluetooth') | |
| 230 | +const isBuiltinModeAvailable = availablePrinterTypes.includes('builtin') | |
| 214 | 231 | |
| 215 | 232 | function refreshCurrentPrinter () { |
| 216 | 233 | currentPrinter.value = getCurrentPrinterSummary() |
| ... | ... | @@ -237,6 +254,7 @@ const connectedDevice = computed(() => { |
| 237 | 254 | }) |
| 238 | 255 | |
| 239 | 256 | function switchType (type: 'bluetooth' | 'builtin') { |
| 257 | + if (!availablePrinterTypes.includes(type)) return | |
| 240 | 258 | printerType.value = type |
| 241 | 259 | if (type === 'bluetooth' && getPrinterType() === 'builtin') { |
| 242 | 260 | clearPrinter() |
| ... | ... | @@ -487,6 +505,7 @@ const handleTestPrint = async () => { |
| 487 | 505 | if (testPrinting.value) return |
| 488 | 506 | testPrinting.value = true |
| 489 | 507 | try { |
| 508 | + uni.showLoading({ title: 'Preparing test print...', mask: true }) | |
| 490 | 509 | await testPrintCurrentPrinter((p) => { |
| 491 | 510 | if (p < 100) uni.showLoading({ title: `Printing ${p}%`, mask: true }) |
| 492 | 511 | }) |
| ... | ... | @@ -533,7 +552,7 @@ onMounted(() => { |
| 533 | 552 | errorMsg.value = '' |
| 534 | 553 | } |
| 535 | 554 | }) |
| 536 | - printerType.value = getPrinterType() || 'bluetooth' | |
| 555 | + printerType.value = preferredPrinterType || 'bluetooth' | |
| 537 | 556 | refreshCurrentPrinter() |
| 538 | 557 | }) |
| 539 | 558 | ... | ... |
美国版/Food Labeling Management App UniApp/src/pages/labels/labels.vue
| ... | ... | @@ -146,6 +146,7 @@ import AppIcon from '../../components/AppIcon.vue' |
| 146 | 146 | import SideMenu from '../../components/SideMenu.vue' |
| 147 | 147 | import LocationPicker from '../../components/LocationPicker.vue' |
| 148 | 148 | import { getStatusBarHeight } from '../../utils/statusBar' |
| 149 | +import { getCurrentPrinterSummary } from '../../utils/print/manager/printerManager' | |
| 149 | 150 | |
| 150 | 151 | const statusBarHeight = getStatusBarHeight() |
| 151 | 152 | const isMenuOpen = ref(false) |
| ... | ... | @@ -160,9 +161,9 @@ const btConnected = ref(false) |
| 160 | 161 | const btDeviceName = ref('') |
| 161 | 162 | |
| 162 | 163 | onShow(() => { |
| 163 | - const name = uni.getStorageSync('btDeviceName') | |
| 164 | - btConnected.value = !!name | |
| 165 | - btDeviceName.value = name || '' | |
| 164 | + const summary = getCurrentPrinterSummary() | |
| 165 | + btConnected.value = summary.type === 'bluetooth' || summary.type === 'builtin' | |
| 166 | + btDeviceName.value = summary.displayName || '' | |
| 166 | 167 | }) |
| 167 | 168 | |
| 168 | 169 | const labelCategories = [ | ... | ... |
美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
| ... | ... | @@ -118,8 +118,11 @@ import SideMenu from '../../components/SideMenu.vue' |
| 118 | 118 | import LocationPicker from '../../components/LocationPicker.vue' |
| 119 | 119 | import { getStatusBarHeight } from '../../utils/statusBar' |
| 120 | 120 | import { generateNextLabelId } from '../../utils/printLog' |
| 121 | -import { getCurrentPrinterSummary, printLabelForCurrentPrinter } from '../../utils/print/manager/printerManager' | |
| 121 | +import { getCurrentPrinterSummary, printSystemTemplateForCurrentPrinter } from '../../utils/print/manager/printerManager' | |
| 122 | +import { PREVIEW_SYSTEM_TEMPLATE } from '../../utils/print/templates/previewSystemTemplate' | |
| 122 | 123 | import chickenLabelImg from '../../static/chicken-lable.png' |
| 124 | +import label1Img from '../../static/lable1.png' | |
| 125 | +import label2Img from '../../static/lable2.png' | |
| 123 | 126 | |
| 124 | 127 | const statusBarHeight = getStatusBarHeight() |
| 125 | 128 | const isPrinting = ref(false) |
| ... | ... | @@ -146,9 +149,9 @@ const labelImage = computed(() => { |
| 146 | 149 | } |
| 147 | 150 | const size = templateSize.value |
| 148 | 151 | if (size.indexOf('2"x6"') >= 0 || size.indexOf('2"x4"') >= 0) { |
| 149 | - return '/static/lable2.png' | |
| 152 | + return label2Img | |
| 150 | 153 | } |
| 151 | - return '/static/lable1.png' | |
| 154 | + return label1Img | |
| 152 | 155 | }) |
| 153 | 156 | |
| 154 | 157 | // 按详情规则与产品列表一致:chicken→Chicken,lable2→Cheese Burger Deluxe,lable1→Syrup |
| ... | ... | @@ -159,6 +162,15 @@ const displayProductName = computed(() => { |
| 159 | 162 | return 'Syrup' |
| 160 | 163 | }) |
| 161 | 164 | |
| 165 | +const printTemplateData = computed(() => ({ | |
| 166 | + product: displayProductName.value, | |
| 167 | + productName: displayProductName.value, | |
| 168 | + category: productCategory.value, | |
| 169 | + labelId: labelId.value, | |
| 170 | + qrCode: labelId.value, | |
| 171 | + barcode: labelId.value, | |
| 172 | +})) | |
| 173 | + | |
| 162 | 174 | onShow(() => { |
| 163 | 175 | const summary = getCurrentPrinterSummary() |
| 164 | 176 | btConnected.value = summary.type === 'bluetooth' || summary.type === 'builtin' |
| ... | ... | @@ -237,11 +249,10 @@ const handlePrint = async () => { |
| 237 | 249 | } |
| 238 | 250 | isPrinting.value = true |
| 239 | 251 | try { |
| 240 | - await printLabelForCurrentPrinter({ | |
| 241 | - productName: displayProductName.value, | |
| 242 | - labelId: labelId.value, | |
| 252 | + uni.showLoading({ title: 'Preparing print data...', mask: true }) | |
| 253 | + await new Promise(resolve => setTimeout(resolve, 30)) | |
| 254 | + await printSystemTemplateForCurrentPrinter(PREVIEW_SYSTEM_TEMPLATE, printTemplateData.value, { | |
| 243 | 255 | printQty: printQty.value, |
| 244 | - extraLine: lastEdited.value, | |
| 245 | 256 | }, (percent) => { |
| 246 | 257 | if (percent >= 100) return |
| 247 | 258 | uni.showLoading({ title: `Printing ${percent}%`, mask: true }) | ... | ... |
美国版/Food Labeling Management App UniApp/src/pages/more/printers.vue
| ... | ... | @@ -24,6 +24,10 @@ |
| 24 | 24 | <view class="debug-card"> |
| 25 | 25 | <text class="debug-title">Debug Status</text> |
| 26 | 26 | <text class="debug-item">Current Mode: {{ debugInfo.currentMode }}</text> |
| 27 | + <text class="debug-item">Available Mode: {{ availablePrinterTypesLabel }}</text> | |
| 28 | + <text class="debug-item">Device Model: {{ deviceIdentity.model || '-' }}</text> | |
| 29 | + <text class="debug-item">Device Brand: {{ deviceIdentity.brand || '-' }}</text> | |
| 30 | + <text class="debug-item">Device Product: {{ deviceIdentity.product || '-' }}</text> | |
| 27 | 31 | <text class="debug-item">Classic Module: {{ debugInfo.classicModuleReady ? 'Ready' : 'Not Ready' }}</text> |
| 28 | 32 | <text class="debug-item">Paired Count: {{ debugInfo.pairedCount }}</text> |
| 29 | 33 | <text class="debug-item">Virtual BT Printer: {{ debugInfo.foundVirtualPrinter ? 'Found' : 'Not Found' }}</text> |
| ... | ... | @@ -39,7 +43,7 @@ |
| 39 | 43 | <view v-if="currentType" class="printer-card connected"> |
| 40 | 44 | <view class="printer-icon"><AppIcon name="printer" size="md" color="white" /></view> |
| 41 | 45 | <view class="printer-info"> |
| 42 | - <text class="printer-name">{{ currentType === 'builtin' ? t('printers.builtin') : (currentBt?.deviceName || 'Bluetooth Printer') }}</text> | |
| 46 | + <text class="printer-name">{{ currentDisplayName }}</text> | |
| 43 | 47 | <text class="printer-loc">{{ currentType === 'builtin' ? '127.0.0.1' : (currentBt?.deviceId || '') }}</text> |
| 44 | 48 | <text class="printer-status">{{ t('printers.connected') }}</text> |
| 45 | 49 | </view> |
| ... | ... | @@ -50,7 +54,7 @@ |
| 50 | 54 | </view> |
| 51 | 55 | |
| 52 | 56 | <!-- Built-in Printer (Show when not connected) --> |
| 53 | - <view v-if="!currentType" class="printer-card" @click="connectBuiltin"> | |
| 57 | + <view v-if="!currentType && isBuiltinModeAvailable" class="printer-card" @click="connectBuiltin"> | |
| 54 | 58 | <view class="printer-icon"><AppIcon name="printer" size="md" color="blue" /></view> |
| 55 | 59 | <view class="printer-info"> |
| 56 | 60 | <text class="printer-name">{{ t('printers.builtin') }}</text> |
| ... | ... | @@ -60,6 +64,7 @@ |
| 60 | 64 | </view> |
| 61 | 65 | |
| 62 | 66 | <!-- Paired Devices --> |
| 67 | + <template v-if="isBluetoothModeAvailable"> | |
| 63 | 68 | <!-- #ifdef APP-PLUS --> |
| 64 | 69 | <view class="section-header"> |
| 65 | 70 | <text class="section-title">{{ t('printers.pairedDevices') }}</text> |
| ... | ... | @@ -108,33 +113,60 @@ |
| 108 | 113 | <view v-if="devices.length === 0 && !isScanning" class="empty-state"> |
| 109 | 114 | <text class="empty-text">{{ t('printers.noDevices') }}</text> |
| 110 | 115 | </view> |
| 116 | + </template> | |
| 111 | 117 | </view> |
| 112 | 118 | |
| 119 | + <canvas | |
| 120 | + canvas-id="test-print-canvas-printers" | |
| 121 | + id="test-print-canvas-printers" | |
| 122 | + :style="{ | |
| 123 | + position: 'fixed', | |
| 124 | + left: '-9999px', | |
| 125 | + top: '-9999px', | |
| 126 | + width: testCanvasWidth + 'px', | |
| 127 | + height: testCanvasHeight + 'px', | |
| 128 | + opacity: 0, | |
| 129 | + pointerEvents: 'none', | |
| 130 | + }" | |
| 131 | + :width="testCanvasWidth" | |
| 132 | + :height="testCanvasHeight" | |
| 133 | + /> | |
| 134 | + | |
| 113 | 135 | <SideMenu v-model="isMenuOpen" /> |
| 114 | 136 | </view> |
| 115 | 137 | </template> |
| 116 | 138 | |
| 117 | 139 | <script setup lang="ts"> |
| 118 | -import { ref, onMounted, onUnmounted } from 'vue' | |
| 140 | +import { ref, onMounted, onUnmounted, getCurrentInstance, nextTick } from 'vue' | |
| 119 | 141 | import { useI18n } from 'vue-i18n' |
| 120 | 142 | import AppIcon from '../../components/AppIcon.vue' |
| 121 | 143 | import SideMenu from '../../components/SideMenu.vue' |
| 144 | +import { getDeviceIdentity } from '../../utils/deviceInfo' | |
| 122 | 145 | import classicBluetooth from '../../utils/print/bluetoothTool.js' |
| 123 | 146 | import { ensureBluetoothPermissions } from '../../utils/print/bluetoothPermissions' |
| 147 | +import { getAvailablePrinterTypes } from '../../utils/print/printerConnection' | |
| 124 | 148 | import { |
| 125 | 149 | connectBluetoothPrinter, |
| 126 | 150 | describeDiscoveredPrinter, |
| 127 | 151 | disconnectCurrentPrinter, |
| 152 | + getCurrentPrinterDriver, | |
| 128 | 153 | getCurrentPrinterSummary, |
| 129 | - testPrintCurrentPrinter, | |
| 154 | + printImageDataForCurrentPrinter, | |
| 130 | 155 | useBuiltinPrinter, |
| 131 | 156 | } from '../../utils/print/manager/printerManager' |
| 157 | +import { getTestPrintCanvasSize, renderTestPrintCanvasImageData } from '../../utils/print/testPrintCanvas' | |
| 132 | 158 | |
| 133 | 159 | const { t } = useI18n() |
| 134 | 160 | const isMenuOpen = ref(false) |
| 161 | +const availablePrinterTypes = getAvailablePrinterTypes() | |
| 162 | +const availablePrinterTypesLabel = availablePrinterTypes.map(item => item === 'builtin' ? 'Built-in' : 'Bluetooth').join(' / ') | |
| 163 | +const deviceIdentity = getDeviceIdentity() | |
| 164 | +const isBluetoothModeAvailable = availablePrinterTypes.includes('bluetooth') | |
| 165 | +const isBuiltinModeAvailable = availablePrinterTypes.includes('builtin') | |
| 135 | 166 | |
| 136 | 167 | const currentType = ref<'' | 'bluetooth' | 'builtin'>('') |
| 137 | 168 | const currentBt = ref<any>(null) |
| 169 | +const currentDisplayName = ref('') | |
| 138 | 170 | const isScanning = ref(false) |
| 139 | 171 | const devices = ref<any[]>([]) |
| 140 | 172 | const pairedDevices = ref<any[]>([]) |
| ... | ... | @@ -149,11 +181,15 @@ const debugInfo = ref({ |
| 149 | 181 | lastBleError: '', |
| 150 | 182 | locationServiceRequired: false, |
| 151 | 183 | }) |
| 184 | +const testCanvasWidth = ref(384) | |
| 185 | +const testCanvasHeight = ref(240) | |
| 186 | +const pageInstance = getCurrentInstance()?.proxy | |
| 152 | 187 | let bleListenerRegistered = false |
| 153 | 188 | |
| 154 | 189 | const refreshStatus = () => { |
| 155 | 190 | const summary = getCurrentPrinterSummary() |
| 156 | 191 | currentType.value = summary.type |
| 192 | + currentDisplayName.value = summary.displayName || '' | |
| 157 | 193 | debugInfo.value.currentMode = summary.type || 'none' |
| 158 | 194 | currentBt.value = summary.type === 'bluetooth' |
| 159 | 195 | ? { |
| ... | ... | @@ -378,16 +414,18 @@ const disconnect = async () => { |
| 378 | 414 | |
| 379 | 415 | const doTestPrint = async () => { |
| 380 | 416 | try { |
| 381 | - if (currentType.value === 'builtin') { | |
| 382 | - uni.showModal({ | |
| 383 | - title: 'Current Mode is Built-in', | |
| 384 | - content: 'This handheld device is currently using Built-in mode. Please disconnect it, then connect "Virtual BT Printer" from the Paired Devices list and test print again.', | |
| 385 | - showCancel: false, | |
| 386 | - }) | |
| 387 | - return | |
| 388 | - } | |
| 389 | - uni.showLoading({ title: t('labels.print.printing') }) | |
| 390 | - await testPrintCurrentPrinter() | |
| 417 | + uni.showLoading({ title: 'Rendering canvas...', mask: true }) | |
| 418 | + const driver = getCurrentPrinterDriver() | |
| 419 | + const size = getTestPrintCanvasSize(driver.imageMaxWidthDots || (driver.protocol === 'esc' ? 384 : 800)) | |
| 420 | + testCanvasWidth.value = size.width | |
| 421 | + testCanvasHeight.value = size.height | |
| 422 | + await nextTick() | |
| 423 | + const imageData = await renderTestPrintCanvasImageData('test-print-canvas-printers', pageInstance, size) | |
| 424 | + await printImageDataForCurrentPrinter(imageData, { printQty: 1 }, (percent) => { | |
| 425 | + if (percent < 100) { | |
| 426 | + uni.showLoading({ title: `Printing ${percent}%`, mask: true }) | |
| 427 | + } | |
| 428 | + }) | |
| 391 | 429 | uni.hideLoading() |
| 392 | 430 | uni.showToast({ title: t('printers.testPrintSuccess') }) |
| 393 | 431 | } catch (e: any) { | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/deviceInfo.ts
0 → 100644
| 1 | +export interface DeviceIdentity { | |
| 2 | + model: string | |
| 3 | + brand: string | |
| 4 | + manufacturer: string | |
| 5 | + product: string | |
| 6 | + device: string | |
| 7 | + platform: string | |
| 8 | +} | |
| 9 | + | |
| 10 | +let cachedDeviceIdentity: DeviceIdentity | null = null | |
| 11 | + | |
| 12 | +function safeText (value: unknown): string { | |
| 13 | + return String(value || '').trim() | |
| 14 | +} | |
| 15 | + | |
| 16 | +export function getDeviceIdentity (): DeviceIdentity { | |
| 17 | + if (cachedDeviceIdentity) return cachedDeviceIdentity | |
| 18 | + | |
| 19 | + const info = (() => { | |
| 20 | + try { | |
| 21 | + return uni.getSystemInfoSync() | |
| 22 | + } catch (_) { | |
| 23 | + return {} as UniApp.GetSystemInfoResult | |
| 24 | + } | |
| 25 | + })() | |
| 26 | + | |
| 27 | + const identity: DeviceIdentity = { | |
| 28 | + model: safeText((info as any).model), | |
| 29 | + brand: safeText((info as any).brand), | |
| 30 | + manufacturer: '', | |
| 31 | + product: '', | |
| 32 | + device: '', | |
| 33 | + platform: safeText((info as any).platform), | |
| 34 | + } | |
| 35 | + | |
| 36 | + // #ifdef APP-PLUS | |
| 37 | + try { | |
| 38 | + const Build = plus.android.importClass('android.os.Build') | |
| 39 | + identity.model = safeText((Build as any).MODEL) || identity.model | |
| 40 | + identity.brand = safeText((Build as any).BRAND) || identity.brand | |
| 41 | + identity.manufacturer = safeText((Build as any).MANUFACTURER) | |
| 42 | + identity.product = safeText((Build as any).PRODUCT) | |
| 43 | + identity.device = safeText((Build as any).DEVICE) | |
| 44 | + } catch (_) {} | |
| 45 | + // #endif | |
| 46 | + | |
| 47 | + cachedDeviceIdentity = identity | |
| 48 | + return identity | |
| 49 | +} | |
| 50 | + | |
| 51 | +export function getDeviceFingerprint (): string { | |
| 52 | + const identity = getDeviceIdentity() | |
| 53 | + return [ | |
| 54 | + identity.brand, | |
| 55 | + identity.manufacturer, | |
| 56 | + identity.model, | |
| 57 | + identity.product, | |
| 58 | + identity.device, | |
| 59 | + ] | |
| 60 | + .filter(Boolean) | |
| 61 | + .join(' | ') | |
| 62 | + .toLowerCase() | |
| 63 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/bluetoothTool.js
| ... | ... | @@ -16,6 +16,7 @@ let IntentFilter = plus.android.importClass('android.content.IntentFilter') |
| 16 | 16 | let BluetoothDevice = plus.android.importClass('android.bluetooth.BluetoothDevice') |
| 17 | 17 | let UUID = plus.android.importClass('java.util.UUID') |
| 18 | 18 | let Toast = plus.android.importClass('android.widget.Toast') |
| 19 | +let Thread = plus.android.importClass('java.lang.Thread') | |
| 19 | 20 | let MY_UUID = UUID.fromString('00001101-0000-1000-8000-00805F9B34FB') |
| 20 | 21 | |
| 21 | 22 | let invoke = plus.android.invoke |
| ... | ... | @@ -62,19 +63,78 @@ function normalizeWriteByte (value) { |
| 62 | 63 | return byte & 0xff |
| 63 | 64 | } |
| 64 | 65 | |
| 66 | +function toSignedWriteByte (value) { | |
| 67 | + const byte = normalizeWriteByte(value) | |
| 68 | + return byte >= 128 ? byte - 256 : byte | |
| 69 | +} | |
| 70 | + | |
| 65 | 71 | function normalizeWriteChunk (byteData, start, end) { |
| 66 | 72 | const out = [] |
| 67 | 73 | for (let i = start; i < end; i++) { |
| 68 | - const value = normalizeWriteByte(byteData[i]) | |
| 69 | - if (value >= 128) { | |
| 70 | - out.push(value - 256) | |
| 71 | - } else { | |
| 72 | - out.push(value) | |
| 73 | - } | |
| 74 | + out.push(toSignedWriteByte(byteData[i])) | |
| 74 | 75 | } |
| 75 | 76 | return out |
| 76 | 77 | } |
| 77 | 78 | |
| 79 | +function isConnectionStateConnected (state) { | |
| 80 | + return String(state || '').trim().toLowerCase() === 'connected' | |
| 81 | +} | |
| 82 | + | |
| 83 | +function runOnUiThread (fn) { | |
| 84 | + // #ifdef APP-PLUS | |
| 85 | + try { | |
| 86 | + if (activity && typeof activity.runOnUiThread === 'function') { | |
| 87 | + const runnable = plus.android.implements('java.lang.Runnable', { | |
| 88 | + run: function () { | |
| 89 | + try { | |
| 90 | + fn && fn() | |
| 91 | + } catch (e) { | |
| 92 | + console.error('runOnUiThread callback failed:', e) | |
| 93 | + } | |
| 94 | + }, | |
| 95 | + }) | |
| 96 | + activity.runOnUiThread(runnable) | |
| 97 | + return | |
| 98 | + } | |
| 99 | + } catch (e) { | |
| 100 | + console.error('runOnUiThread failed:', e) | |
| 101 | + } | |
| 102 | + // #endif | |
| 103 | + try { | |
| 104 | + setTimeout(() => { | |
| 105 | + try { | |
| 106 | + fn && fn() | |
| 107 | + } catch (e) { | |
| 108 | + console.error('setTimeout callback failed:', e) | |
| 109 | + } | |
| 110 | + }, 0) | |
| 111 | + } catch (e) { | |
| 112 | + console.error('async callback fallback failed:', e) | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 116 | +function startBackgroundTask (task) { | |
| 117 | + // #ifdef APP-PLUS | |
| 118 | + const runnable = plus.android.implements('java.lang.Runnable', { | |
| 119 | + run: function () { | |
| 120 | + try { | |
| 121 | + task && task() | |
| 122 | + } catch (e) { | |
| 123 | + console.error('Background task failed:', e) | |
| 124 | + } | |
| 125 | + }, | |
| 126 | + }) | |
| 127 | + const thread = new Thread(runnable) | |
| 128 | + thread.start() | |
| 129 | + return | |
| 130 | + // #endif | |
| 131 | + try { | |
| 132 | + task && task() | |
| 133 | + } catch (e) { | |
| 134 | + console.error('Fallback task failed:', e) | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 78 | 138 | function createSocketCandidates (device) { |
| 79 | 139 | return [ |
| 80 | 140 | { |
| ... | ... | @@ -109,6 +169,7 @@ var blueToothTool = { |
| 109 | 169 | lastSendError: '', |
| 110 | 170 | outputReady: false, |
| 111 | 171 | lastSendMode: 'idle', |
| 172 | + isSending: false, | |
| 112 | 173 | }, |
| 113 | 174 | options: { |
| 114 | 175 | listenBTStatusCallback: function (state) {}, |
| ... | ... | @@ -489,26 +550,37 @@ var blueToothTool = { |
| 489 | 550 | }, |
| 490 | 551 | sendByteData (byteData) { |
| 491 | 552 | // #ifdef APP-PLUS |
| 492 | - if (!this.ensureConnection(this.state.lastAddress)) { | |
| 553 | + if (!btOutStream) { | |
| 554 | + this.state.outputReady = false | |
| 555 | + this.state.connectionState = 'error' | |
| 556 | + this.setErrorState('Classic Bluetooth output stream not ready', 'send') | |
| 557 | + this.shortToast('Not connected') | |
| 558 | + return false | |
| 559 | + } | |
| 560 | + const socketConnected = this.isSocketConnected() | |
| 561 | + const connectionUsable = socketConnected || isConnectionStateConnected(this.state.connectionState) | |
| 562 | + if (!connectionUsable) { | |
| 563 | + this.state.connectionState = 'error' | |
| 564 | + this.setErrorState('Classic Bluetooth connection is not ready', 'send') | |
| 493 | 565 | this.shortToast('Not connected') |
| 494 | 566 | return false |
| 495 | 567 | } |
| 496 | 568 | try { |
| 497 | - const CHUNK_SIZE = 512 | |
| 569 | + const CHUNK_SIZE = 4096 | |
| 498 | 570 | this.state.lastSendMode = 'chunk-write' |
| 499 | 571 | this.state.lastSendError = '' |
| 500 | 572 | for (let i = 0; i < byteData.length; i += CHUNK_SIZE) { |
| 501 | - const chunk = normalizeWriteChunk(byteData, i, Math.min(i + CHUNK_SIZE, byteData.length)) | |
| 573 | + const chunk = byteData.slice(i, Math.min(i + CHUNK_SIZE, byteData.length)) | |
| 502 | 574 | try { |
| 503 | 575 | btOutStream.write(chunk) |
| 504 | 576 | } catch (writeChunkError) { |
| 505 | 577 | this.state.lastSendMode = 'byte-write-fallback' |
| 578 | + const signedChunk = normalizeWriteChunk(byteData, i, Math.min(i + CHUNK_SIZE, byteData.length)) | |
| 506 | 579 | for (let j = 0; j < chunk.length; j++) { |
| 507 | - invoke(btOutStream, 'write', normalizeWriteByte(chunk[j])) | |
| 580 | + invoke(btOutStream, 'write', normalizeWriteByte(signedChunk[j])) | |
| 508 | 581 | } |
| 509 | 582 | } |
| 510 | 583 | } |
| 511 | - invoke(btOutStream, 'flush') | |
| 512 | 584 | return true |
| 513 | 585 | } catch (e) { |
| 514 | 586 | const message = getErrorMessage(e) |
| ... | ... | @@ -520,6 +592,53 @@ var blueToothTool = { |
| 520 | 592 | // #endif |
| 521 | 593 | return false |
| 522 | 594 | }, |
| 595 | + sendByteDataAsync (byteData, callback) { | |
| 596 | + // #ifdef APP-PLUS | |
| 597 | + if (!btOutStream) { | |
| 598 | + this.state.outputReady = false | |
| 599 | + this.state.connectionState = 'error' | |
| 600 | + this.setErrorState('Classic Bluetooth output stream not ready', 'send') | |
| 601 | + runOnUiThread(() => { | |
| 602 | + callback && callback(false, this.getLastError()) | |
| 603 | + }) | |
| 604 | + return false | |
| 605 | + } | |
| 606 | + const socketConnected = this.isSocketConnected() | |
| 607 | + const connectionUsable = socketConnected || isConnectionStateConnected(this.state.connectionState) | |
| 608 | + if (!connectionUsable) { | |
| 609 | + this.state.connectionState = 'error' | |
| 610 | + this.setErrorState('Classic Bluetooth connection is not ready', 'send') | |
| 611 | + runOnUiThread(() => { | |
| 612 | + callback && callback(false, this.getLastError()) | |
| 613 | + }) | |
| 614 | + return false | |
| 615 | + } | |
| 616 | + this.state.isSending = true | |
| 617 | + this.state.lastSendError = '' | |
| 618 | + startBackgroundTask(() => { | |
| 619 | + let ok = false | |
| 620 | + let errorMessage = '' | |
| 621 | + try { | |
| 622 | + ok = this.sendByteData(byteData) | |
| 623 | + if (!ok) { | |
| 624 | + errorMessage = this.getLastError() || 'Classic Bluetooth send failed' | |
| 625 | + } | |
| 626 | + } catch (e) { | |
| 627 | + errorMessage = getErrorMessage(e) | |
| 628 | + this.setErrorState(errorMessage, 'send') | |
| 629 | + this.state.connectionState = 'error' | |
| 630 | + } finally { | |
| 631 | + this.state.isSending = false | |
| 632 | + } | |
| 633 | + runOnUiThread(() => { | |
| 634 | + callback && callback(ok, errorMessage) | |
| 635 | + }) | |
| 636 | + }) | |
| 637 | + return true | |
| 638 | + // #endif | |
| 639 | + callback && callback(false, 'Classic Bluetooth is only available in the app.') | |
| 640 | + return false | |
| 641 | + }, | |
| 523 | 642 | isSocketConnected () { |
| 524 | 643 | // #ifdef APP-PLUS |
| 525 | 644 | if (!btSocket) return false |
| ... | ... | @@ -537,7 +656,7 @@ var blueToothTool = { |
| 537 | 656 | if (targetAddress) { |
| 538 | 657 | this.state.lastAddress = targetAddress |
| 539 | 658 | } |
| 540 | - if (btOutStream && this.isSocketConnected()) { | |
| 659 | + if (btOutStream && (this.isSocketConnected() || isConnectionStateConnected(this.state.connectionState))) { | |
| 541 | 660 | this.state.outputReady = true |
| 542 | 661 | return true |
| 543 | 662 | } | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/drivers/d320fax.ts
美国版/Food Labeling Management App UniApp/src/utils/print/drivers/genericTsc.ts
| ... | ... | @@ -22,6 +22,8 @@ export const genericTscDriver: PrinterDriver = { |
| 22 | 22 | displayName: 'Generic TSC Printer', |
| 23 | 23 | protocol: 'tsc', |
| 24 | 24 | preferredBleMtu: 20, |
| 25 | + imageMaxWidthDots: 800, | |
| 26 | + imageDpi: 203, | |
| 25 | 27 | keywords: GENERIC_TSC_KEYWORDS, |
| 26 | 28 | matches (device) { |
| 27 | 29 | return scoreByKeywords(device, GENERIC_TSC_KEYWORDS) | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/drivers/gpR3.ts
美国版/Food Labeling Management App UniApp/src/utils/print/imageRaster.ts
0 → 100644
| 1 | +import type { MonochromeImageData, PrintImageOptions, PrinterDriver, RawImageDataSource } from './types/printer' | |
| 2 | + | |
| 3 | +const DEFAULT_IMAGE_THRESHOLD = 180 | |
| 4 | + | |
| 5 | +function yieldToUi (): Promise<void> { | |
| 6 | + return new Promise((resolve) => { | |
| 7 | + setTimeout(resolve, 0) | |
| 8 | + }) | |
| 9 | +} | |
| 10 | + | |
| 11 | +function ensureMultipleOf8 (value: number, maxValue?: number): number { | |
| 12 | + const safeMax = maxValue && maxValue > 0 ? Math.max(8, Math.floor(maxValue)) : 0 | |
| 13 | + let width = Math.max(8, Math.round(value || 0)) | |
| 14 | + if (safeMax && width > safeMax) width = safeMax | |
| 15 | + if (width % 8 !== 0) { | |
| 16 | + width += 8 - (width % 8) | |
| 17 | + if (safeMax && width > safeMax) { | |
| 18 | + width = safeMax - (safeMax % 8) | |
| 19 | + if (width <= 0) width = 8 | |
| 20 | + } | |
| 21 | + } | |
| 22 | + return width | |
| 23 | +} | |
| 24 | + | |
| 25 | +function normalizeBase64Payload (source: string): string { | |
| 26 | + const value = String(source || '').trim() | |
| 27 | + if (!value) return '' | |
| 28 | + if (value.startsWith('data:image/')) { | |
| 29 | + const index = value.indexOf(',') | |
| 30 | + return index >= 0 ? value.slice(index + 1) : '' | |
| 31 | + } | |
| 32 | + if (/^[A-Za-z0-9+/=\r\n]+$/.test(value) && value.length > 128) { | |
| 33 | + return value.replace(/\s+/g, '') | |
| 34 | + } | |
| 35 | + return '' | |
| 36 | +} | |
| 37 | + | |
| 38 | +function resolveLocalImagePath (source: string): string { | |
| 39 | + let path = String(source || '').trim() | |
| 40 | + if (!path) return '' | |
| 41 | + if (path.startsWith('file://')) { | |
| 42 | + path = path.replace(/^file:\/\//, '') | |
| 43 | + } | |
| 44 | + // #ifdef APP-PLUS | |
| 45 | + try { | |
| 46 | + const converted = plus.io.convertLocalFileSystemURL(path) | |
| 47 | + if (converted) path = converted | |
| 48 | + } catch (_) {} | |
| 49 | + // #endif | |
| 50 | + try { | |
| 51 | + path = decodeURIComponent(path) | |
| 52 | + } catch (_) {} | |
| 53 | + return path | |
| 54 | +} | |
| 55 | + | |
| 56 | +function getDefaultMaxWidthDots (driver: PrinterDriver): number { | |
| 57 | + if (driver.imageMaxWidthDots && driver.imageMaxWidthDots > 0) return driver.imageMaxWidthDots | |
| 58 | + return driver.protocol === 'esc' ? 384 : 800 | |
| 59 | +} | |
| 60 | + | |
| 61 | +export function rasterizeImageData ( | |
| 62 | + source: RawImageDataSource, | |
| 63 | + options: PrintImageOptions = {} | |
| 64 | +): MonochromeImageData { | |
| 65 | + const sourceWidth = Math.max(1, Math.round(source.width || 0)) | |
| 66 | + const sourceHeight = Math.max(1, Math.round(source.height || 0)) | |
| 67 | + const targetWidth = ensureMultipleOf8(sourceWidth) | |
| 68 | + const threshold = options.threshold != null ? Number(options.threshold) : DEFAULT_IMAGE_THRESHOLD | |
| 69 | + const pixels: number[] = new Array(targetWidth * sourceHeight).fill(0) | |
| 70 | + | |
| 71 | + for (let y = 0; y < sourceHeight; y++) { | |
| 72 | + for (let x = 0; x < sourceWidth; x++) { | |
| 73 | + const index = (y * sourceWidth + x) * 4 | |
| 74 | + const alpha = Number(source.data[index + 3] ?? 255) | |
| 75 | + const red = Number(source.data[index] ?? 255) | |
| 76 | + const green = Number(source.data[index + 1] ?? 255) | |
| 77 | + const blue = Number(source.data[index + 2] ?? 255) | |
| 78 | + const gray = red * 0.299 + green * 0.587 + blue * 0.114 | |
| 79 | + pixels[y * targetWidth + x] = alpha <= 10 || gray > threshold ? 0 : 1 | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + return { | |
| 84 | + width: targetWidth, | |
| 85 | + height: sourceHeight, | |
| 86 | + pixels, | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +export async function rasterizeImageForPrinter ( | |
| 91 | + source: string, | |
| 92 | + driver: PrinterDriver, | |
| 93 | + options: PrintImageOptions = {} | |
| 94 | +): Promise<MonochromeImageData> { | |
| 95 | + // #ifdef APP-PLUS | |
| 96 | + const rawSource = String(source || '').trim() | |
| 97 | + if (!rawSource) { | |
| 98 | + throw new Error('IMAGE_SOURCE_EMPTY') | |
| 99 | + } | |
| 100 | + | |
| 101 | + const BitmapFactory = plus.android.importClass('android.graphics.BitmapFactory') | |
| 102 | + const Bitmap = plus.android.importClass('android.graphics.Bitmap') | |
| 103 | + const Base64 = plus.android.importClass('android.util.Base64') | |
| 104 | + | |
| 105 | + const base64Payload = normalizeBase64Payload(rawSource) | |
| 106 | + let sourceBitmap: any = null | |
| 107 | + | |
| 108 | + if (base64Payload) { | |
| 109 | + const bytes = Base64.decode(base64Payload, 0) | |
| 110 | + sourceBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length) | |
| 111 | + } else { | |
| 112 | + const localPath = resolveLocalImagePath(rawSource) | |
| 113 | + sourceBitmap = BitmapFactory.decodeFile(localPath) | |
| 114 | + } | |
| 115 | + | |
| 116 | + if (!sourceBitmap) { | |
| 117 | + throw new Error('IMAGE_DECODE_FAILED') | |
| 118 | + } | |
| 119 | + | |
| 120 | + const sourceWidth = Number(sourceBitmap.getWidth ? sourceBitmap.getWidth() : 0) | |
| 121 | + const sourceHeight = Number(sourceBitmap.getHeight ? sourceBitmap.getHeight() : 0) | |
| 122 | + if (!sourceWidth || !sourceHeight) { | |
| 123 | + try { | |
| 124 | + sourceBitmap.recycle && sourceBitmap.recycle() | |
| 125 | + } catch (_) {} | |
| 126 | + throw new Error('IMAGE_SIZE_INVALID') | |
| 127 | + } | |
| 128 | + | |
| 129 | + const protocolMaxWidth = getDefaultMaxWidthDots(driver) | |
| 130 | + const maxWidthDots = options.maxWidthDots && options.maxWidthDots > 0 ? options.maxWidthDots : protocolMaxWidth | |
| 131 | + const targetWidth = ensureMultipleOf8(options.targetWidthDots || sourceWidth, maxWidthDots) | |
| 132 | + const aspectRatio = sourceHeight / sourceWidth | |
| 133 | + const targetHeight = Math.max( | |
| 134 | + 1, | |
| 135 | + Math.round(options.targetHeightDots || (targetWidth * aspectRatio)) | |
| 136 | + ) | |
| 137 | + const threshold = options.threshold != null ? Number(options.threshold) : DEFAULT_IMAGE_THRESHOLD | |
| 138 | + | |
| 139 | + const scaledBitmap = Bitmap.createScaledBitmap(sourceBitmap, targetWidth, targetHeight, true) | |
| 140 | + const rasterPixels: number[] = new Array(targetWidth * targetHeight) | |
| 141 | + | |
| 142 | + for (let y = 0; y < targetHeight; y++) { | |
| 143 | + if (y > 0 && y % 16 === 0) { | |
| 144 | + await yieldToUi() | |
| 145 | + } | |
| 146 | + for (let x = 0; x < targetWidth; x++) { | |
| 147 | + const color = Number(scaledBitmap.getPixel(x, y)) | |
| 148 | + const alpha = (color >>> 24) & 0xff | |
| 149 | + const red = (color >>> 16) & 0xff | |
| 150 | + const green = (color >>> 8) & 0xff | |
| 151 | + const blue = color & 0xff | |
| 152 | + const gray = red * 0.299 + green * 0.587 + blue * 0.114 | |
| 153 | + rasterPixels[y * targetWidth + x] = alpha <= 10 || gray > threshold ? 0 : 1 | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 157 | + try { | |
| 158 | + if (scaledBitmap !== sourceBitmap && sourceBitmap?.recycle) sourceBitmap.recycle() | |
| 159 | + } catch (_) {} | |
| 160 | + try { | |
| 161 | + scaledBitmap?.recycle && scaledBitmap.recycle() | |
| 162 | + } catch (_) {} | |
| 163 | + | |
| 164 | + return { | |
| 165 | + width: targetWidth, | |
| 166 | + height: targetHeight, | |
| 167 | + pixels: rasterPixels, | |
| 168 | + } | |
| 169 | + // #endif | |
| 170 | + | |
| 171 | + // #ifndef APP-PLUS | |
| 172 | + throw new Error('IMAGE_PRINT_APP_ONLY') | |
| 173 | + // #endif | |
| 174 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts
| ... | ... | @@ -8,14 +8,29 @@ import { |
| 8 | 8 | setBuiltinPrinter, |
| 9 | 9 | } from '../printerConnection' |
| 10 | 10 | import classicBluetooth from '../bluetoothTool.js' |
| 11 | +import { rasterizeImageData, rasterizeImageForPrinter } from '../imageRaster' | |
| 12 | +import { buildEscPosImageData, buildEscPosTemplateData } from '../protocols/escPosBuilder' | |
| 13 | +import { buildTscImageData, buildTscTemplateData } from '../protocols/tscProtocol' | |
| 14 | +import { adaptSystemLabelTemplate } from '../systemTemplateAdapter' | |
| 11 | 15 | import { describePrinterCandidate, getPrinterDriverByKey, resolvePrinterDriver } from './driverRegistry' |
| 12 | 16 | import type { |
| 13 | 17 | CurrentPrinterSummary, |
| 14 | 18 | LabelPrintPayload, |
| 19 | + LabelTemplateData, | |
| 20 | + RawImageDataSource, | |
| 21 | + PrintImageOptions, | |
| 15 | 22 | PrinterCandidate, |
| 16 | 23 | PrinterDriver, |
| 24 | + StructuredLabelTemplate, | |
| 25 | + SystemLabelTemplate, | |
| 17 | 26 | } from '../types/printer' |
| 18 | 27 | |
| 28 | +function getPrinterTypeDisplayName (type: '' | 'bluetooth' | 'builtin'): string { | |
| 29 | + if (type === 'bluetooth') return 'Bluetooth' | |
| 30 | + if (type === 'builtin') return 'Built-in' | |
| 31 | + return '' | |
| 32 | +} | |
| 33 | + | |
| 19 | 34 | function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDriver): Promise<void> { |
| 20 | 35 | return new Promise((resolve, reject) => { |
| 21 | 36 | // #ifdef APP-PLUS |
| ... | ... | @@ -159,7 +174,7 @@ export function getCurrentPrinterSummary (): CurrentPrinterSummary { |
| 159 | 174 | if (type === 'builtin') { |
| 160 | 175 | return { |
| 161 | 176 | type, |
| 162 | - displayName: 'Built-in Printer', | |
| 177 | + displayName: getPrinterTypeDisplayName(type), | |
| 163 | 178 | deviceId: 'builtin', |
| 164 | 179 | driverKey: driver.key, |
| 165 | 180 | driverName: driver.displayName, |
| ... | ... | @@ -172,7 +187,7 @@ export function getCurrentPrinterSummary (): CurrentPrinterSummary { |
| 172 | 187 | if (connection) { |
| 173 | 188 | return { |
| 174 | 189 | type, |
| 175 | - displayName: connection.deviceName || driver.displayName, | |
| 190 | + displayName: getPrinterTypeDisplayName(type), | |
| 176 | 191 | deviceId: connection.deviceId, |
| 177 | 192 | driverKey: driver.key, |
| 178 | 193 | driverName: driver.displayName, |
| ... | ... | @@ -207,6 +222,74 @@ export async function printLabelForCurrentPrinter ( |
| 207 | 222 | return driver |
| 208 | 223 | } |
| 209 | 224 | |
| 225 | +export async function printImageForCurrentPrinter ( | |
| 226 | + imageSource: string, | |
| 227 | + options: PrintImageOptions = {}, | |
| 228 | + onProgress?: (percent: number) => void | |
| 229 | +): Promise<PrinterDriver> { | |
| 230 | + const driver = getCurrentPrinterDriver() | |
| 231 | + const raster = await rasterizeImageForPrinter(imageSource, driver, options) | |
| 232 | + if (onProgress) onProgress(5) | |
| 233 | + let data: number[] = [] | |
| 234 | + | |
| 235 | + if (driver.protocol === 'esc') { | |
| 236 | + data = buildEscPosImageData(raster, options) | |
| 237 | + } else { | |
| 238 | + data = buildTscImageData(raster, options, driver.imageDpi || 203) | |
| 239 | + } | |
| 240 | + | |
| 241 | + await sendToPrinter(data, onProgress) | |
| 242 | + return driver | |
| 243 | +} | |
| 244 | + | |
| 245 | +export async function printImageDataForCurrentPrinter ( | |
| 246 | + imageData: RawImageDataSource, | |
| 247 | + options: PrintImageOptions = {}, | |
| 248 | + onProgress?: (percent: number) => void | |
| 249 | +): Promise<PrinterDriver> { | |
| 250 | + const driver = getCurrentPrinterDriver() | |
| 251 | + const raster = rasterizeImageData(imageData, options) | |
| 252 | + if (onProgress) onProgress(5) | |
| 253 | + const data = driver.protocol === 'esc' | |
| 254 | + ? buildEscPosImageData(raster, options) | |
| 255 | + : buildTscImageData(raster, options, driver.imageDpi || 203) | |
| 256 | + await sendToPrinter(data, onProgress) | |
| 257 | + return driver | |
| 258 | +} | |
| 259 | + | |
| 260 | +export async function printTemplateForCurrentPrinter ( | |
| 261 | + template: StructuredLabelTemplate, | |
| 262 | + data: LabelTemplateData = {}, | |
| 263 | + onProgress?: (percent: number) => void | |
| 264 | +): Promise<PrinterDriver> { | |
| 265 | + const driver = getCurrentPrinterDriver() | |
| 266 | + const bytes = driver.protocol === 'esc' | |
| 267 | + ? buildEscPosTemplateData(template, data) | |
| 268 | + : buildTscTemplateData(template, data) | |
| 269 | + await sendToPrinter(bytes, onProgress) | |
| 270 | + return driver | |
| 271 | +} | |
| 272 | + | |
| 273 | +export async function printSystemTemplateForCurrentPrinter ( | |
| 274 | + template: SystemLabelTemplate, | |
| 275 | + data: LabelTemplateData = {}, | |
| 276 | + options: { | |
| 277 | + printQty?: number | |
| 278 | + } = {}, | |
| 279 | + onProgress?: (percent: number) => void | |
| 280 | +): Promise<PrinterDriver> { | |
| 281 | + const driver = getCurrentPrinterDriver() | |
| 282 | + const structuredTemplate = adaptSystemLabelTemplate(template, data, { | |
| 283 | + dpi: driver.imageDpi || 203, | |
| 284 | + printQty: options.printQty || 1, | |
| 285 | + }) | |
| 286 | + const bytes = driver.protocol === 'esc' | |
| 287 | + ? buildEscPosTemplateData(structuredTemplate) | |
| 288 | + : buildTscTemplateData(structuredTemplate) | |
| 289 | + await sendToPrinter(bytes, onProgress) | |
| 290 | + return driver | |
| 291 | +} | |
| 292 | + | |
| 210 | 293 | export function describeDiscoveredPrinter (device: PrinterCandidate) { |
| 211 | 294 | return describePrinterCandidate(device) |
| 212 | 295 | } | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/printerConnection.ts
| ... | ... | @@ -3,6 +3,7 @@ |
| 3 | 3 | */ |
| 4 | 4 | import type { ActiveBtDeviceType, PrinterType } from './types/printer' |
| 5 | 5 | import classicBluetooth from './bluetoothTool.js' |
| 6 | +import { getDeviceFingerprint } from '../deviceInfo' | |
| 6 | 7 | |
| 7 | 8 | const STORAGE_PRINTER_TYPE = 'printerType' |
| 8 | 9 | const STORAGE_BT_DEVICE_ID = 'btDeviceId' |
| ... | ... | @@ -15,6 +16,10 @@ const STORAGE_BUILTIN_PORT = 'builtinPort' |
| 15 | 16 | const STORAGE_PRINTER_DRIVER_KEY = 'printerDriverKey' |
| 16 | 17 | |
| 17 | 18 | const BUILTIN_PROBE_PORTS = [9100, 4000, 9000, 6000] |
| 19 | +const BUILTIN_PRINTER_DEVICE_KEYWORDS: string[] = [ | |
| 20 | + // 在这里补充需要走 Built-in 的设备型号关键字(小写匹配) | |
| 21 | + // 例如:'desktop-aio', 'pos-terminal-x1' | |
| 22 | +] | |
| 18 | 23 | export type BtDeviceType = ActiveBtDeviceType |
| 19 | 24 | |
| 20 | 25 | export const PrinterStorageKeys = { |
| ... | ... | @@ -71,7 +76,11 @@ export function clearPrinter () { |
| 71 | 76 | const BLE_MTU_DEFAULT = 20 |
| 72 | 77 | |
| 73 | 78 | export function getPrinterType (): PrinterType | '' { |
| 74 | - return (uni.getStorageSync(STORAGE_PRINTER_TYPE) as PrinterType) || '' | |
| 79 | + const type = (uni.getStorageSync(STORAGE_PRINTER_TYPE) as PrinterType) || '' | |
| 80 | + if (!type) return '' | |
| 81 | + if (getAvailablePrinterTypes().includes(type)) return type | |
| 82 | + clearPrinter() | |
| 83 | + return '' | |
| 75 | 84 | } |
| 76 | 85 | |
| 77 | 86 | export function getCurrentPrinterDriverKey (): string { |
| ... | ... | @@ -116,6 +125,58 @@ export function isBuiltinConnected (): boolean { |
| 116 | 125 | return getPrinterType() === 'builtin' |
| 117 | 126 | } |
| 118 | 127 | |
| 128 | +export function isBuiltinPrinterAvailable (): boolean { | |
| 129 | + // #ifdef APP-PLUS | |
| 130 | + try { | |
| 131 | + const plugin = (uni as any)?.requireNativePlugin | |
| 132 | + ? (uni as any).requireNativePlugin('moe-tcp-client') | |
| 133 | + : null | |
| 134 | + return !!plugin | |
| 135 | + } catch (_) { | |
| 136 | + return false | |
| 137 | + } | |
| 138 | + // #endif | |
| 139 | + // #ifndef APP-PLUS | |
| 140 | + return false | |
| 141 | + // #endif | |
| 142 | +} | |
| 143 | + | |
| 144 | +export function isBuiltinPrinterEnabledByDeviceModel (): boolean { | |
| 145 | + const fingerprint = getDeviceFingerprint() | |
| 146 | + if (!fingerprint) return false | |
| 147 | + return BUILTIN_PRINTER_DEVICE_KEYWORDS.some(keyword => fingerprint.includes(String(keyword || '').toLowerCase())) | |
| 148 | +} | |
| 149 | + | |
| 150 | +export function getAvailablePrinterTypes (): PrinterType[] { | |
| 151 | + if (isBuiltinPrinterAvailable() && isBuiltinPrinterEnabledByDeviceModel()) { | |
| 152 | + return ['builtin'] | |
| 153 | + } | |
| 154 | + return ['bluetooth'] | |
| 155 | +} | |
| 156 | + | |
| 157 | +function buildClassicBluetoothError (message: string, deviceId?: string): Error { | |
| 158 | + const baseMessage = String(message || 'Classic Bluetooth error') | |
| 159 | + if (!classicBluetooth || typeof classicBluetooth.getDebugState !== 'function') { | |
| 160 | + return new Error(baseMessage) | |
| 161 | + } | |
| 162 | + try { | |
| 163 | + const debugState = classicBluetooth.getDebugState() || {} | |
| 164 | + const details: string[] = [] | |
| 165 | + if (deviceId) details.push(`device=${deviceId}`) | |
| 166 | + if (debugState.lastSocketStrategy) details.push(`socket=${debugState.lastSocketStrategy}`) | |
| 167 | + if (debugState.connectionState) details.push(`state=${debugState.connectionState}`) | |
| 168 | + if (typeof debugState.socketConnected === 'boolean') details.push(`connected=${debugState.socketConnected}`) | |
| 169 | + if (typeof debugState.outputReady === 'boolean') details.push(`outputReady=${debugState.outputReady}`) | |
| 170 | + if (debugState.lastSendMode) details.push(`sendMode=${debugState.lastSendMode}`) | |
| 171 | + if (debugState.lastSendError) details.push(`sendError=${debugState.lastSendError}`) | |
| 172 | + else if (debugState.lastError) details.push(`lastError=${debugState.lastError}`) | |
| 173 | + if (details.length === 0) return new Error(baseMessage) | |
| 174 | + return new Error(`${baseMessage}\n${details.join('\n')}`) | |
| 175 | + } catch (_) { | |
| 176 | + return new Error(baseMessage) | |
| 177 | + } | |
| 178 | +} | |
| 179 | + | |
| 119 | 180 | /** |
| 120 | 181 | * 发送打印数据到当前已选打印机 |
| 121 | 182 | * @param data 字节数组(TSC 指令) |
| ... | ... | @@ -200,29 +261,53 @@ function sendViaClassic ( |
| 200 | 261 | reject(new Error('Classic Bluetooth not available')) |
| 201 | 262 | return |
| 202 | 263 | } |
| 203 | - const ready = typeof classicBluetooth.ensureConnection === 'function' | |
| 204 | - ? classicBluetooth.ensureConnection(conn.deviceId) | |
| 264 | + const debugState = typeof classicBluetooth.getDebugState === 'function' | |
| 265 | + ? classicBluetooth.getDebugState() | |
| 266 | + : null | |
| 267 | + const connectionState = String(debugState?.connectionState || '').trim().toLowerCase() | |
| 268 | + const ready = debugState | |
| 269 | + ? (!!debugState.outputReady && (!!debugState.socketConnected || connectionState === 'connected')) | |
| 205 | 270 | : true |
| 206 | 271 | if (!ready) { |
| 207 | 272 | const errorMessage = typeof classicBluetooth.getLastError === 'function' |
| 208 | 273 | ? classicBluetooth.getLastError() |
| 209 | 274 | : '' |
| 210 | - reject(new Error(errorMessage || 'Classic Bluetooth connection is not ready')) | |
| 275 | + reject(buildClassicBluetoothError(errorMessage || 'Classic Bluetooth connection is not ready', conn.deviceId)) | |
| 276 | + return | |
| 277 | + } | |
| 278 | + | |
| 279 | + const sendData = data.map((byte) => { | |
| 280 | + const value = byte & 0xff | |
| 281 | + return value >= 128 ? value - 256 : value | |
| 282 | + }) | |
| 283 | + | |
| 284 | + if (typeof classicBluetooth.sendByteDataAsync === 'function') { | |
| 285 | + classicBluetooth.sendByteDataAsync(sendData, (ok: boolean, errorMessage?: string) => { | |
| 286 | + if (onProgress) onProgress(100) | |
| 287 | + if (ok) { | |
| 288 | + resolve() | |
| 289 | + return | |
| 290 | + } | |
| 291 | + reject(buildClassicBluetoothError( | |
| 292 | + errorMessage || classicBluetooth.getLastError?.() || 'Classic Bluetooth send failed', | |
| 293 | + conn.deviceId | |
| 294 | + )) | |
| 295 | + }) | |
| 211 | 296 | return |
| 212 | 297 | } |
| 213 | 298 | |
| 214 | - const sendData = data.map((byte) => byte & 0xff) | |
| 215 | 299 | const ok = classicBluetooth.sendByteData(sendData) |
| 216 | 300 | if (onProgress) onProgress(100) |
| 217 | - if (ok) resolve() | |
| 218 | - else { | |
| 219 | - const errorMessage = typeof classicBluetooth.getLastError === 'function' | |
| 220 | - ? classicBluetooth.getLastError() | |
| 221 | - : '' | |
| 222 | - reject(new Error(errorMessage || 'Classic Bluetooth send failed')) | |
| 301 | + if (ok) { | |
| 302 | + resolve() | |
| 303 | + return | |
| 223 | 304 | } |
| 305 | + const errorMessage = typeof classicBluetooth.getLastError === 'function' | |
| 306 | + ? classicBluetooth.getLastError() | |
| 307 | + : '' | |
| 308 | + reject(buildClassicBluetoothError(errorMessage || 'Classic Bluetooth send failed', conn.deviceId)) | |
| 224 | 309 | } catch (e: any) { |
| 225 | - reject(e) | |
| 310 | + reject(buildClassicBluetoothError(e?.message || String(e || 'Classic Bluetooth send exception'), conn.deviceId)) | |
| 226 | 311 | } |
| 227 | 312 | }) |
| 228 | 313 | // #endif | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/protocols/escPosBuilder.ts
| 1 | -import type { LabelPrintPayload } from '../types/printer' | |
| 1 | +import type { | |
| 2 | + LabelPrintPayload, | |
| 3 | + LabelTemplateData, | |
| 4 | + MonochromeImageData, | |
| 5 | + PrintImageOptions, | |
| 6 | + StructuredLabelTemplate, | |
| 7 | +} from '../types/printer' | |
| 8 | +import { resolveEscTemplate } from '../templateRenderer' | |
| 9 | +import { createTestPrintTemplate } from '../templates/testPrintTemplate' | |
| 2 | 10 | |
| 3 | 11 | function stringToBytes (str: string): number[] { |
| 4 | 12 | const out: number[] = [] |
| ... | ... | @@ -35,6 +43,10 @@ function appendLine (out: number[], text = '') { |
| 35 | 43 | out.push(0x0a) |
| 36 | 44 | } |
| 37 | 45 | |
| 46 | +function appendBytes (out: number[], bytes: number[]) { | |
| 47 | + for (let i = 0; i < bytes.length; i++) out.push(bytes[i]) | |
| 48 | +} | |
| 49 | + | |
| 38 | 50 | function appendAlign (out: number[], align: 0 | 1 | 2) { |
| 39 | 51 | out.push(0x1b, 0x61, align) |
| 40 | 52 | } |
| ... | ... | @@ -48,6 +60,47 @@ function appendSize (out: number[], width = 0, height = 0) { |
| 48 | 60 | out.push(0x1d, 0x21, value) |
| 49 | 61 | } |
| 50 | 62 | |
| 63 | +function clamp (value: number, min: number, max: number): number { | |
| 64 | + return Math.max(min, Math.min(max, Math.round(value))) | |
| 65 | +} | |
| 66 | + | |
| 67 | +function normalizeEscBarcodeType (value?: string): number { | |
| 68 | + const key = String(value || 'CODE128').trim().toUpperCase() | |
| 69 | + const map: Record<string, number> = { | |
| 70 | + UPCA: 65, | |
| 71 | + UPCE: 66, | |
| 72 | + EAN13: 67, | |
| 73 | + EAN8: 68, | |
| 74 | + CODE39: 69, | |
| 75 | + ITF: 70, | |
| 76 | + CODABAR: 71, | |
| 77 | + CODE93: 72, | |
| 78 | + CODE128: 73, | |
| 79 | + } | |
| 80 | + return map[key] || 73 | |
| 81 | +} | |
| 82 | + | |
| 83 | +function normalizeEscQrLevel (value?: string): number { | |
| 84 | + const key = String(value || 'M').trim().toUpperCase() | |
| 85 | + const map: Record<string, number> = { | |
| 86 | + L: 48, | |
| 87 | + M: 49, | |
| 88 | + Q: 50, | |
| 89 | + H: 51, | |
| 90 | + } | |
| 91 | + return map[key] || 49 | |
| 92 | +} | |
| 93 | + | |
| 94 | +function appendHorizontalRule (out: number[], width = 32) { | |
| 95 | + appendLine(out, '+' + '-'.repeat(Math.max(width - 2, 0)) + '+') | |
| 96 | +} | |
| 97 | + | |
| 98 | +function appendBoxLine (out: number[], text = '', width = 32) { | |
| 99 | + const innerWidth = Math.max(width - 4, 0) | |
| 100 | + const value = text.length > innerWidth ? text.slice(0, innerWidth) : text.padEnd(innerWidth, ' ') | |
| 101 | + appendLine(out, `| ${value} |`) | |
| 102 | +} | |
| 103 | + | |
| 51 | 104 | function createEscDocument (builder: (out: number[]) => void): number[] { |
| 52 | 105 | const out: number[] = [] |
| 53 | 106 | out.push(0x1b, 0x40) |
| ... | ... | @@ -56,21 +109,80 @@ function createEscDocument (builder: (out: number[]) => void): number[] { |
| 56 | 109 | return out |
| 57 | 110 | } |
| 58 | 111 | |
| 112 | +function appendRasterImage (out: number[], image: MonochromeImageData) { | |
| 113 | + const bytesPerRow = image.width / 8 | |
| 114 | + const xL = bytesPerRow & 0xff | |
| 115 | + const xH = (bytesPerRow >> 8) & 0xff | |
| 116 | + const yL = image.height & 0xff | |
| 117 | + const yH = (image.height >> 8) & 0xff | |
| 118 | + out.push(0x1d, 0x76, 0x30, 0x00, xL, xH, yL, yH) | |
| 119 | + for (let y = 0; y < image.height; y++) { | |
| 120 | + for (let byteIndex = 0; byteIndex < bytesPerRow; byteIndex++) { | |
| 121 | + let value = 0 | |
| 122 | + for (let bit = 0; bit < 8; bit++) { | |
| 123 | + const x = byteIndex * 8 + bit | |
| 124 | + const pixel = image.pixels[y * image.width + x] | |
| 125 | + if (pixel) value |= 1 << (7 - bit) | |
| 126 | + } | |
| 127 | + out.push(value & 0xff) | |
| 128 | + } | |
| 129 | + } | |
| 130 | +} | |
| 131 | + | |
| 132 | +function appendBarcode (out: number[], item: { | |
| 133 | + value: string | |
| 134 | + align?: 0 | 1 | 2 | |
| 135 | + symbology?: string | |
| 136 | + height?: number | |
| 137 | + width?: number | |
| 138 | + showText?: boolean | |
| 139 | +}) { | |
| 140 | + const value = String(item.value || '') | |
| 141 | + if (!value) return | |
| 142 | + appendAlign(out, item.align ?? 1) | |
| 143 | + out.push(0x1d, 0x48, item.showText === false ? 0 : 2) | |
| 144 | + out.push(0x1d, 0x68, clamp(item.height || 96, 1, 255)) | |
| 145 | + out.push(0x1d, 0x77, clamp(item.width || 3, 2, 6)) | |
| 146 | + | |
| 147 | + const type = normalizeEscBarcodeType(item.symbology) | |
| 148 | + const bytes = stringToBytes(value) | |
| 149 | + | |
| 150 | + if (type >= 73) { | |
| 151 | + out.push(0x1d, 0x6b, type, clamp(bytes.length, 0, 255)) | |
| 152 | + appendBytes(out, bytes) | |
| 153 | + } else { | |
| 154 | + out.push(0x1d, 0x6b, type) | |
| 155 | + appendBytes(out, bytes) | |
| 156 | + out.push(0x00) | |
| 157 | + } | |
| 158 | + out.push(0x0a) | |
| 159 | +} | |
| 160 | + | |
| 161 | +function appendQrCode (out: number[], item: { | |
| 162 | + value: string | |
| 163 | + align?: 0 | 1 | 2 | |
| 164 | + size?: number | |
| 165 | + level?: 'L' | 'M' | 'Q' | 'H' | |
| 166 | +}) { | |
| 167 | + const value = String(item.value || '') | |
| 168 | + if (!value) return | |
| 169 | + const bytes = stringToBytes(value) | |
| 170 | + const storeLength = bytes.length + 3 | |
| 171 | + const pL = storeLength & 0xff | |
| 172 | + const pH = (storeLength >> 8) & 0xff | |
| 173 | + | |
| 174 | + appendAlign(out, item.align ?? 1) | |
| 175 | + out.push(0x1d, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00) | |
| 176 | + out.push(0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, clamp(item.size || 5, 1, 16)) | |
| 177 | + out.push(0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, normalizeEscQrLevel(item.level)) | |
| 178 | + out.push(0x1d, 0x28, 0x6b, pL, pH, 0x31, 0x50, 0x30) | |
| 179 | + appendBytes(out, bytes) | |
| 180 | + out.push(0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30) | |
| 181 | + out.push(0x0a) | |
| 182 | +} | |
| 183 | + | |
| 59 | 184 | export function buildEscPosTestPrintData (): number[] { |
| 60 | - return createEscDocument((out) => { | |
| 61 | - appendAlign(out, 1) | |
| 62 | - appendBold(out, true) | |
| 63 | - appendSize(out, 1, 1) | |
| 64 | - appendLine(out, 'TEST PRINT') | |
| 65 | - appendBold(out, false) | |
| 66 | - appendSize(out, 0, 0) | |
| 67 | - appendLine(out, 'GP-R3 / ESC-POS') | |
| 68 | - appendLine(out, 'Food Label System') | |
| 69 | - appendLine(out, '-----------------------------') | |
| 70 | - appendLine(out, 'Connection OK') | |
| 71 | - appendLine(out, 'Protocol OK') | |
| 72 | - appendLine(out, '-----------------------------') | |
| 73 | - }) | |
| 185 | + return buildEscPosTemplateData(createTestPrintTemplate()) | |
| 74 | 186 | } |
| 75 | 187 | |
| 76 | 188 | export function buildEscPosLabelData (payload: LabelPrintPayload): number[] { |
| ... | ... | @@ -98,3 +210,53 @@ export function buildEscPosLabelData (payload: LabelPrintPayload): number[] { |
| 98 | 210 | appendLine(out, '-----------------------------') |
| 99 | 211 | }) |
| 100 | 212 | } |
| 213 | + | |
| 214 | +export function buildEscPosImageData ( | |
| 215 | + image: MonochromeImageData, | |
| 216 | + options: PrintImageOptions = {} | |
| 217 | +): number[] { | |
| 218 | + const printQty = Math.max(1, Math.round(options.printQty || 1)) | |
| 219 | + return createEscDocument((out) => { | |
| 220 | + for (let i = 0; i < printQty; i++) { | |
| 221 | + appendAlign(out, 1) | |
| 222 | + appendRasterImage(out, image) | |
| 223 | + appendLine(out) | |
| 224 | + appendLine(out) | |
| 225 | + } | |
| 226 | + }) | |
| 227 | +} | |
| 228 | + | |
| 229 | +export function buildEscPosTemplateData ( | |
| 230 | + template: StructuredLabelTemplate, | |
| 231 | + data: LabelTemplateData = {} | |
| 232 | +): number[] { | |
| 233 | + const resolved = resolveEscTemplate(template, data) | |
| 234 | + const printQty = Math.max(1, Math.round(resolved.printQty || 1)) | |
| 235 | + const feedLines = Math.max(1, Math.round(resolved.feedLines || 4)) | |
| 236 | + | |
| 237 | + return createEscDocument((out) => { | |
| 238 | + for (let i = 0; i < printQty; i++) { | |
| 239 | + resolved.items.forEach((item) => { | |
| 240 | + if (item.type === 'rule') { | |
| 241 | + appendHorizontalRule(out, item.width || 32) | |
| 242 | + return | |
| 243 | + } | |
| 244 | + if (item.type === 'qrcode') { | |
| 245 | + appendQrCode(out, item) | |
| 246 | + return | |
| 247 | + } | |
| 248 | + if (item.type === 'barcode') { | |
| 249 | + appendBarcode(out, item) | |
| 250 | + return | |
| 251 | + } | |
| 252 | + appendAlign(out, item.align ?? 0) | |
| 253 | + appendBold(out, !!item.bold) | |
| 254 | + appendSize(out, item.widthScale || 0, item.heightScale || 0) | |
| 255 | + appendLine(out, item.text) | |
| 256 | + appendBold(out, false) | |
| 257 | + appendSize(out, 0, 0) | |
| 258 | + }) | |
| 259 | + out.push(0x1b, 0x64, feedLines) | |
| 260 | + } | |
| 261 | + }) | |
| 262 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/protocols/tscProtocol.ts
| 1 | -import type { LabelPrintPayload } from '../types/printer' | |
| 2 | -import { buildTestTscLabel, buildTscLabel } from '../tscLabelBuilder' | |
| 1 | +import type { | |
| 2 | + LabelPrintPayload, | |
| 3 | + LabelTemplateData, | |
| 4 | + MonochromeImageData, | |
| 5 | + PrintImageOptions, | |
| 6 | + StructuredLabelTemplate, | |
| 7 | +} from '../types/printer' | |
| 8 | +import { resolveTscTemplate } from '../templateRenderer' | |
| 9 | +import { createTestPrintTemplate } from '../templates/testPrintTemplate' | |
| 10 | +import { buildTestTscLabel, buildTscImageLabel, buildTscLabel, buildTscTemplateLabel } from '../tscLabelBuilder' | |
| 3 | 11 | |
| 4 | 12 | export function buildTscTestPrintData (): number[] { |
| 5 | - return buildTestTscLabel() | |
| 13 | + const template = createTestPrintTemplate(203, 1) | |
| 14 | + if (!template.tsc) { | |
| 15 | + throw new Error('Test template does not support TSC printers') | |
| 16 | + } | |
| 17 | + return buildTscTemplateLabel(template.tsc) | |
| 6 | 18 | } |
| 7 | 19 | |
| 8 | 20 | export function buildTscLabelData (payload: LabelPrintPayload): number[] { |
| 9 | 21 | return buildTscLabel(payload) |
| 10 | 22 | } |
| 23 | + | |
| 24 | +export function buildTscImageData ( | |
| 25 | + image: MonochromeImageData, | |
| 26 | + options: PrintImageOptions = {}, | |
| 27 | + dpi = 203 | |
| 28 | +): number[] { | |
| 29 | + return buildTscImageLabel(image, options, dpi) | |
| 30 | +} | |
| 31 | + | |
| 32 | +export function buildTscTemplateData ( | |
| 33 | + template: StructuredLabelTemplate, | |
| 34 | + data: LabelTemplateData = {} | |
| 35 | +): number[] { | |
| 36 | + return buildTscTemplateLabel(resolveTscTemplate(template, data)) | |
| 37 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/systemTemplateAdapter.ts
0 → 100644
| 1 | +import { applyTemplateData } from './templateRenderer' | |
| 2 | +import type { | |
| 3 | + EscTemplateItem, | |
| 4 | + LabelTemplateData, | |
| 5 | + PrinterTemplateUnit, | |
| 6 | + StructuredLabelTemplate, | |
| 7 | + TscTemplateItem, | |
| 8 | + StructuredTscTemplate, | |
| 9 | + StructuredEscTemplate, | |
| 10 | + SystemLabelTemplate, | |
| 11 | + SystemTemplateElementBase, | |
| 12 | + SystemTemplateTextAlign, | |
| 13 | +} from './types/printer' | |
| 14 | + | |
| 15 | +const DESIGN_DPI = 96 | |
| 16 | + | |
| 17 | +function roundNumber (value: number, digits = 1): number { | |
| 18 | + const factor = Math.pow(10, digits) | |
| 19 | + return Math.round(value * factor) / factor | |
| 20 | +} | |
| 21 | + | |
| 22 | +function toMillimeter (value: number, unit: PrinterTemplateUnit = 'inch'): number { | |
| 23 | + if (unit === 'mm') return value | |
| 24 | + if (unit === 'cm') return value * 10 | |
| 25 | + if (unit === 'px') return value / DESIGN_DPI * 25.4 | |
| 26 | + return value * 25.4 | |
| 27 | +} | |
| 28 | + | |
| 29 | +function templateWidthPx (template: SystemLabelTemplate): number { | |
| 30 | + return toMillimeter(template.width, template.unit || 'inch') / 25.4 * DESIGN_DPI | |
| 31 | +} | |
| 32 | + | |
| 33 | +function pxToDots (value: number, dpi: number): number { | |
| 34 | + return Math.max(0, Math.round((Number(value) || 0) * dpi / DESIGN_DPI)) | |
| 35 | +} | |
| 36 | + | |
| 37 | +function clamp (value: number, min: number, max: number): number { | |
| 38 | + return Math.max(min, Math.min(max, Math.round(value))) | |
| 39 | +} | |
| 40 | + | |
| 41 | +function sortElements (elements: SystemTemplateElementBase[]): SystemTemplateElementBase[] { | |
| 42 | + return [...elements].sort((a, b) => { | |
| 43 | + if (a.y !== b.y) return a.y - b.y | |
| 44 | + return a.x - b.x | |
| 45 | + }) | |
| 46 | +} | |
| 47 | + | |
| 48 | +function getConfigString ( | |
| 49 | + config: Record<string, any>, | |
| 50 | + keys: string[], | |
| 51 | + fallback = '' | |
| 52 | +): string { | |
| 53 | + for (let i = 0; i < keys.length; i++) { | |
| 54 | + const value = config?.[keys[i]] | |
| 55 | + if (value != null && value !== '') return String(value) | |
| 56 | + } | |
| 57 | + return fallback | |
| 58 | +} | |
| 59 | + | |
| 60 | +function getConfigNumber ( | |
| 61 | + config: Record<string, any>, | |
| 62 | + keys: string[], | |
| 63 | + fallback = 0 | |
| 64 | +): number { | |
| 65 | + for (let i = 0; i < keys.length; i++) { | |
| 66 | + const value = Number(config?.[keys[i]]) | |
| 67 | + if (!Number.isNaN(value) && Number.isFinite(value)) return value | |
| 68 | + } | |
| 69 | + return fallback | |
| 70 | +} | |
| 71 | + | |
| 72 | +function toCamelCaseKey (value: string): string { | |
| 73 | + return value | |
| 74 | + .toLowerCase() | |
| 75 | + .split(/[_\s-]+/) | |
| 76 | + .map((segment, index) => index === 0 | |
| 77 | + ? segment | |
| 78 | + : segment.charAt(0).toUpperCase() + segment.slice(1)) | |
| 79 | + .join('') | |
| 80 | +} | |
| 81 | + | |
| 82 | +function resolveBindingKey (element: SystemTemplateElementBase): string { | |
| 83 | + const config = element.config || {} | |
| 84 | + const explicit = getConfigString(config, ['dataKey', 'field', 'bindField', 'key', 'valueKey']) | |
| 85 | + if (explicit) return explicit | |
| 86 | + | |
| 87 | + const type = String(element.type || '').toUpperCase() | |
| 88 | + const map: Record<string, string> = { | |
| 89 | + TEXT_PRODUCT: 'productName', | |
| 90 | + TEXT_LABEL_ID: 'labelId', | |
| 91 | + TEXT_CATEGORY: 'category', | |
| 92 | + TEXT_PRICE: 'price', | |
| 93 | + TEXT_DATE: 'date', | |
| 94 | + TEXT_TIME: 'time', | |
| 95 | + QRCODE: 'qrCode', | |
| 96 | + BARCODE: 'barcode', | |
| 97 | + } | |
| 98 | + if (map[type]) return map[type] | |
| 99 | + | |
| 100 | + const pureType = type | |
| 101 | + .replace(/^TEXT_/, '') | |
| 102 | + .replace(/^FIELD_/, '') | |
| 103 | + .replace(/^VALUE_/, '') | |
| 104 | + return pureType ? toCamelCaseKey(pureType) : '' | |
| 105 | +} | |
| 106 | + | |
| 107 | +function resolveTemplateFieldValue (data: LabelTemplateData, key: string): string { | |
| 108 | + if (!key) return '' | |
| 109 | + const candidates = [key] | |
| 110 | + if (key === 'productName') candidates.push('product') | |
| 111 | + if (key === 'product') candidates.push('productName') | |
| 112 | + if (key === 'qrCode') candidates.push('labelId', 'barcode') | |
| 113 | + if (key === 'barcode') candidates.push('labelId', 'qrCode') | |
| 114 | + | |
| 115 | + for (let i = 0; i < candidates.length; i++) { | |
| 116 | + const value = data[candidates[i]] | |
| 117 | + if (value != null) return String(value) | |
| 118 | + } | |
| 119 | + return '' | |
| 120 | +} | |
| 121 | + | |
| 122 | +function resolveElementText ( | |
| 123 | + element: SystemTemplateElementBase, | |
| 124 | + data: LabelTemplateData | |
| 125 | +): string { | |
| 126 | + const config = element.config || {} | |
| 127 | + const hasText = config.text != null && config.text !== '' | |
| 128 | + if (hasText && String(element.type || '').toUpperCase() === 'TEXT_STATIC') { | |
| 129 | + return applyTemplateData(String(config.text), data) | |
| 130 | + } | |
| 131 | + if (hasText && String(config.text).includes('{{')) { | |
| 132 | + return applyTemplateData(String(config.text), data) | |
| 133 | + } | |
| 134 | + const bindingKey = resolveBindingKey(element) | |
| 135 | + const boundValue = resolveTemplateFieldValue(data, bindingKey) | |
| 136 | + if (boundValue) return boundValue | |
| 137 | + if (hasText) return applyTemplateData(String(config.text), data) | |
| 138 | + return '' | |
| 139 | +} | |
| 140 | + | |
| 141 | +function resolveElementDataValue ( | |
| 142 | + element: SystemTemplateElementBase, | |
| 143 | + data: LabelTemplateData | |
| 144 | +): string { | |
| 145 | + const config = element.config || {} | |
| 146 | + const raw = getConfigString(config, ['data', 'value']) | |
| 147 | + if (raw) return applyTemplateData(raw, data) | |
| 148 | + return resolveTemplateFieldValue(data, resolveBindingKey(element)) | |
| 149 | +} | |
| 150 | + | |
| 151 | +function resolveElementAlign ( | |
| 152 | + element: SystemTemplateElementBase, | |
| 153 | + pageWidthPx: number | |
| 154 | +): SystemTemplateTextAlign { | |
| 155 | + const config = element.config || {} | |
| 156 | + const align = String(config.textAlign || '').toLowerCase() | |
| 157 | + if (align === 'left' || align === 'center' || align === 'right') return align as SystemTemplateTextAlign | |
| 158 | + const centerX = (Number(element.x) || 0) + (Number(element.width) || 0) / 2 | |
| 159 | + if (centerX <= pageWidthPx * 0.33) return 'left' | |
| 160 | + if (centerX >= pageWidthPx * 0.67) return 'right' | |
| 161 | + return 'center' | |
| 162 | +} | |
| 163 | + | |
| 164 | +function toEscAlign (align: SystemTemplateTextAlign): 0 | 1 | 2 { | |
| 165 | + if (align === 'center') return 1 | |
| 166 | + if (align === 'right') return 2 | |
| 167 | + return 0 | |
| 168 | +} | |
| 169 | + | |
| 170 | +function resolveRotation (value?: string): number { | |
| 171 | + return value === 'vertical' ? 90 : 0 | |
| 172 | +} | |
| 173 | + | |
| 174 | +function normalizeQrLevel (value?: string): 'L' | 'M' | 'Q' | 'H' { | |
| 175 | + const key = String(value || 'M').trim().toUpperCase() | |
| 176 | + if (key === 'L' || key === 'M' || key === 'Q' || key === 'H') return key | |
| 177 | + return 'M' | |
| 178 | +} | |
| 179 | + | |
| 180 | +function estimateTextWidthDots (text: string, fontDots: number): number { | |
| 181 | + let total = 0 | |
| 182 | + for (let i = 0; i < text.length; i++) { | |
| 183 | + const code = text.charCodeAt(i) | |
| 184 | + total += code > 255 ? fontDots : fontDots * 0.6 | |
| 185 | + } | |
| 186 | + return Math.round(total) | |
| 187 | +} | |
| 188 | + | |
| 189 | +function resolveTextScale (fontSizePx: number, dpi: number): number { | |
| 190 | + const targetDots = Math.max(12, Math.round(fontSizePx * dpi / DESIGN_DPI)) | |
| 191 | + return clamp(targetDots / 24, 1, 7) | |
| 192 | +} | |
| 193 | + | |
| 194 | +function resolveTextX (params: { | |
| 195 | + align: SystemTemplateTextAlign | |
| 196 | + xPx: number | |
| 197 | + widthPx: number | |
| 198 | + dpi: number | |
| 199 | + text: string | |
| 200 | + scale: number | |
| 201 | +}): number { | |
| 202 | + const left = pxToDots(params.xPx, params.dpi) | |
| 203 | + if (params.align === 'left') return left | |
| 204 | + | |
| 205 | + const boxWidth = pxToDots(params.widthPx, params.dpi) | |
| 206 | + const fontDots = Math.max(24, params.scale * 24) | |
| 207 | + const textWidth = estimateTextWidthDots(params.text, fontDots) | |
| 208 | + if (params.align === 'center') { | |
| 209 | + return Math.max(0, left + Math.round(Math.max(0, boxWidth - textWidth) / 2)) | |
| 210 | + } | |
| 211 | + return Math.max(0, left + Math.max(0, boxWidth - textWidth)) | |
| 212 | +} | |
| 213 | + | |
| 214 | +function buildTscTemplate ( | |
| 215 | + template: SystemLabelTemplate, | |
| 216 | + data: LabelTemplateData, | |
| 217 | + dpi: number, | |
| 218 | + printQty: number | |
| 219 | +): StructuredTscTemplate { | |
| 220 | + const widthMm = roundNumber(toMillimeter(template.width, template.unit || 'inch')) | |
| 221 | + const heightMm = roundNumber(toMillimeter(template.height, template.unit || 'inch')) | |
| 222 | + const items: TscTemplateItem[] = [] | |
| 223 | + | |
| 224 | + sortElements(template.elements).forEach((element) => { | |
| 225 | + const config = element.config || {} | |
| 226 | + const type = String(element.type || '').toUpperCase() | |
| 227 | + | |
| 228 | + if (type.startsWith('TEXT_')) { | |
| 229 | + const text = resolveElementText(element, data) | |
| 230 | + if (!text) return | |
| 231 | + const scale = resolveTextScale(getConfigNumber(config, ['fontSize'], 14), dpi) | |
| 232 | + const align = resolveElementAlign(element, templateWidthPx(template)) | |
| 233 | + items.push({ | |
| 234 | + type: 'text', | |
| 235 | + x: resolveTextX({ | |
| 236 | + align, | |
| 237 | + xPx: element.x, | |
| 238 | + widthPx: element.width, | |
| 239 | + dpi, | |
| 240 | + text, | |
| 241 | + scale, | |
| 242 | + }), | |
| 243 | + y: pxToDots(element.y, dpi), | |
| 244 | + text, | |
| 245 | + font: 'TSS24.BF2', | |
| 246 | + rotation: resolveRotation(element.rotation), | |
| 247 | + xScale: scale, | |
| 248 | + yScale: scale, | |
| 249 | + }) | |
| 250 | + return | |
| 251 | + } | |
| 252 | + | |
| 253 | + if (type === 'QRCODE') { | |
| 254 | + const value = resolveElementDataValue(element, data) | |
| 255 | + if (!value) return | |
| 256 | + items.push({ | |
| 257 | + type: 'qrcode', | |
| 258 | + x: pxToDots(element.x, dpi), | |
| 259 | + y: pxToDots(element.y, dpi), | |
| 260 | + value, | |
| 261 | + level: normalizeQrLevel(getConfigString(config, ['errorLevel'], 'M')), | |
| 262 | + cellWidth: clamp(Math.min(element.width, element.height) / 20, 2, 10), | |
| 263 | + mode: 'A', | |
| 264 | + }) | |
| 265 | + return | |
| 266 | + } | |
| 267 | + | |
| 268 | + if (type === 'BARCODE') { | |
| 269 | + const value = resolveElementDataValue(element, data) | |
| 270 | + if (!value) return | |
| 271 | + items.push({ | |
| 272 | + type: 'barcode', | |
| 273 | + x: pxToDots(element.x, dpi), | |
| 274 | + y: pxToDots(element.y, dpi), | |
| 275 | + value, | |
| 276 | + symbology: getConfigString(config, ['barcodeType'], 'CODE128'), | |
| 277 | + height: Math.max(20, pxToDots(element.height, dpi)), | |
| 278 | + readable: config.showText !== false, | |
| 279 | + rotation: resolveRotation(getConfigString(config, ['orientation'], element.rotation || 'horizontal')), | |
| 280 | + narrow: clamp(element.width / Math.max(40, value.length * 6), 1, 4), | |
| 281 | + wide: clamp(element.width / Math.max(24, value.length * 3), 2, 6), | |
| 282 | + }) | |
| 283 | + } | |
| 284 | + }) | |
| 285 | + | |
| 286 | + return { | |
| 287 | + widthMm, | |
| 288 | + heightMm, | |
| 289 | + gapMm: 0, | |
| 290 | + density: 14, | |
| 291 | + speed: 5, | |
| 292 | + printQty, | |
| 293 | + items, | |
| 294 | + } | |
| 295 | +} | |
| 296 | + | |
| 297 | +function buildEscTemplate ( | |
| 298 | + template: SystemLabelTemplate, | |
| 299 | + data: LabelTemplateData, | |
| 300 | + printQty: number | |
| 301 | +): StructuredEscTemplate { | |
| 302 | + const pageWidth = templateWidthPx(template) | |
| 303 | + const items: EscTemplateItem[] = [] | |
| 304 | + | |
| 305 | + sortElements(template.elements).forEach((element) => { | |
| 306 | + const config = element.config || {} | |
| 307 | + const type = String(element.type || '').toUpperCase() | |
| 308 | + const align = toEscAlign(resolveElementAlign(element, pageWidth)) | |
| 309 | + | |
| 310 | + if (type.startsWith('TEXT_')) { | |
| 311 | + const text = resolveElementText(element, data) | |
| 312 | + if (!text) return | |
| 313 | + const fontSize = getConfigNumber(config, ['fontSize'], 14) | |
| 314 | + const scale = fontSize >= 28 ? 2 : 1 | |
| 315 | + items.push({ | |
| 316 | + type: 'text', | |
| 317 | + text, | |
| 318 | + align, | |
| 319 | + bold: String(config.fontWeight || '').toLowerCase() === 'bold', | |
| 320 | + widthScale: scale, | |
| 321 | + heightScale: scale, | |
| 322 | + }) | |
| 323 | + return | |
| 324 | + } | |
| 325 | + | |
| 326 | + if (type === 'QRCODE') { | |
| 327 | + const value = resolveElementDataValue(element, data) | |
| 328 | + if (!value) return | |
| 329 | + items.push({ | |
| 330 | + type: 'qrcode', | |
| 331 | + value, | |
| 332 | + align, | |
| 333 | + size: clamp(Math.min(element.width, element.height) / 24, 3, 10), | |
| 334 | + level: normalizeQrLevel(getConfigString(config, ['errorLevel'], 'M')), | |
| 335 | + }) | |
| 336 | + return | |
| 337 | + } | |
| 338 | + | |
| 339 | + if (type === 'BARCODE') { | |
| 340 | + const value = resolveElementDataValue(element, data) | |
| 341 | + if (!value) return | |
| 342 | + items.push({ | |
| 343 | + type: 'barcode', | |
| 344 | + value, | |
| 345 | + align, | |
| 346 | + symbology: getConfigString(config, ['barcodeType'], 'CODE128'), | |
| 347 | + height: clamp(element.height * 2, 48, 180), | |
| 348 | + width: clamp(element.width / Math.max(48, value.length * 4), 2, 6), | |
| 349 | + showText: config.showText !== false, | |
| 350 | + }) | |
| 351 | + } | |
| 352 | + }) | |
| 353 | + | |
| 354 | + return { | |
| 355 | + printQty, | |
| 356 | + feedLines: 3, | |
| 357 | + items, | |
| 358 | + } | |
| 359 | +} | |
| 360 | + | |
| 361 | +export function adaptSystemLabelTemplate ( | |
| 362 | + template: SystemLabelTemplate, | |
| 363 | + data: LabelTemplateData = {}, | |
| 364 | + options: { | |
| 365 | + dpi?: number | |
| 366 | + printQty?: number | |
| 367 | + } = {} | |
| 368 | +): StructuredLabelTemplate { | |
| 369 | + const dpi = options.dpi || 203 | |
| 370 | + const printQty = Math.max(1, Math.round(options.printQty || 1)) | |
| 371 | + return { | |
| 372 | + key: template.id || template.name || 'system-label-template', | |
| 373 | + tsc: buildTscTemplate(template, data, dpi, printQty), | |
| 374 | + esc: buildEscTemplate(template, data, printQty), | |
| 375 | + } | |
| 376 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/templateRenderer.ts
0 → 100644
| 1 | +import type { | |
| 2 | + LabelTemplateData, | |
| 3 | + StructuredEscTemplate, | |
| 4 | + StructuredLabelTemplate, | |
| 5 | + StructuredTscTemplate, | |
| 6 | +} from './types/printer' | |
| 7 | + | |
| 8 | +function resolveTemplateValue (key: string, data: LabelTemplateData): string { | |
| 9 | + const value = data[key] | |
| 10 | + if (value == null) return '' | |
| 11 | + return String(value) | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function applyTemplateData (content: string, data: LabelTemplateData = {}): string { | |
| 15 | + return String(content || '').replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_, key: string) => resolveTemplateValue(key, data)) | |
| 16 | +} | |
| 17 | + | |
| 18 | +export function resolveTscTemplate ( | |
| 19 | + template: StructuredLabelTemplate, | |
| 20 | + data: LabelTemplateData = {} | |
| 21 | +): StructuredTscTemplate { | |
| 22 | + if (!template.tsc) { | |
| 23 | + throw new Error(`Template "${template.key}" does not support TSC printers`) | |
| 24 | + } | |
| 25 | + return { | |
| 26 | + ...template.tsc, | |
| 27 | + items: template.tsc.items.map((item) => { | |
| 28 | + if (item.type === 'text') { | |
| 29 | + return { ...item, text: applyTemplateData(item.text, data) } | |
| 30 | + } | |
| 31 | + if (item.type === 'qrcode' || item.type === 'barcode') { | |
| 32 | + return { ...item, value: applyTemplateData(item.value, data) } | |
| 33 | + } | |
| 34 | + return { ...item } | |
| 35 | + }), | |
| 36 | + } | |
| 37 | +} | |
| 38 | + | |
| 39 | +export function resolveEscTemplate ( | |
| 40 | + template: StructuredLabelTemplate, | |
| 41 | + data: LabelTemplateData = {} | |
| 42 | +): StructuredEscTemplate { | |
| 43 | + if (!template.esc) { | |
| 44 | + throw new Error(`Template "${template.key}" does not support ESC printers`) | |
| 45 | + } | |
| 46 | + return { | |
| 47 | + ...template.esc, | |
| 48 | + items: template.esc.items.map((item) => { | |
| 49 | + if (item.type === 'text') { | |
| 50 | + return { ...item, text: applyTemplateData(item.text, data) } | |
| 51 | + } | |
| 52 | + if (item.type === 'qrcode' || item.type === 'barcode') { | |
| 53 | + return { ...item, value: applyTemplateData(item.value, data) } | |
| 54 | + } | |
| 55 | + return { ...item } | |
| 56 | + }), | |
| 57 | + } | |
| 58 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/templates/previewSystemTemplate.ts
0 → 100644
| 1 | +import type { SystemLabelTemplate } from '../types/printer' | |
| 2 | + | |
| 3 | +export const PREVIEW_SYSTEM_TEMPLATE: SystemLabelTemplate = { | |
| 4 | + id: 'template-preview-json-print', | |
| 5 | + name: 'Preview JSON Template', | |
| 6 | + labelType: 'PRICE', | |
| 7 | + unit: 'inch', | |
| 8 | + width: 4, | |
| 9 | + height: 2, | |
| 10 | + appliedLocation: 'ALL', | |
| 11 | + showRuler: true, | |
| 12 | + showGrid: true, | |
| 13 | + elements: [ | |
| 14 | + { | |
| 15 | + id: 'el-title', | |
| 16 | + type: 'TEXT_STATIC', | |
| 17 | + x: 104, | |
| 18 | + y: 16, | |
| 19 | + width: 160, | |
| 20 | + height: 24, | |
| 21 | + rotation: 'horizontal', | |
| 22 | + border: 'none', | |
| 23 | + config: { | |
| 24 | + text: 'FOOD LABEL', | |
| 25 | + fontFamily: 'Arial', | |
| 26 | + fontSize: 14, | |
| 27 | + fontWeight: 'bold', | |
| 28 | + textAlign: 'center', | |
| 29 | + }, | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + id: 'el-product-name', | |
| 33 | + type: 'TEXT_PRODUCT', | |
| 34 | + x: 96, | |
| 35 | + y: 128, | |
| 36 | + width: 120, | |
| 37 | + height: 24, | |
| 38 | + rotation: 'horizontal', | |
| 39 | + border: 'none', | |
| 40 | + config: { | |
| 41 | + text: 'Product', | |
| 42 | + fontFamily: 'Arial', | |
| 43 | + fontSize: 14, | |
| 44 | + fontWeight: 'normal', | |
| 45 | + textAlign: 'left', | |
| 46 | + }, | |
| 47 | + }, | |
| 48 | + { | |
| 49 | + id: 'el-category', | |
| 50 | + type: 'TEXT_STATIC', | |
| 51 | + x: 88, | |
| 52 | + y: 152, | |
| 53 | + width: 140, | |
| 54 | + height: 24, | |
| 55 | + rotation: 'horizontal', | |
| 56 | + border: 'none', | |
| 57 | + config: { | |
| 58 | + text: '{{category}}', | |
| 59 | + fontFamily: 'Arial', | |
| 60 | + fontSize: 14, | |
| 61 | + fontWeight: 'normal', | |
| 62 | + textAlign: 'left', | |
| 63 | + }, | |
| 64 | + }, | |
| 65 | + { | |
| 66 | + id: 'el-qrcode', | |
| 67 | + type: 'QRCODE', | |
| 68 | + x: 128, | |
| 69 | + y: 40, | |
| 70 | + width: 80, | |
| 71 | + height: 80, | |
| 72 | + rotation: 'horizontal', | |
| 73 | + border: 'none', | |
| 74 | + config: { | |
| 75 | + data: '{{qrCode}}', | |
| 76 | + errorLevel: 'M', | |
| 77 | + }, | |
| 78 | + }, | |
| 79 | + { | |
| 80 | + id: 'el-barcode', | |
| 81 | + type: 'BARCODE', | |
| 82 | + x: 208, | |
| 83 | + y: 128, | |
| 84 | + width: 160, | |
| 85 | + height: 48, | |
| 86 | + rotation: 'horizontal', | |
| 87 | + border: 'none', | |
| 88 | + config: { | |
| 89 | + barcodeType: 'CODE128', | |
| 90 | + data: '{{barcode}}', | |
| 91 | + showText: true, | |
| 92 | + orientation: 'horizontal', | |
| 93 | + }, | |
| 94 | + }, | |
| 95 | + ], | |
| 96 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/templates/test.json
0 → 100644
| 1 | +{ | |
| 2 | + "id": "template-1773998862063", | |
| 3 | + "name": "未命名模板", | |
| 4 | + "labelType": "PRICE", | |
| 5 | + "unit": "inch", | |
| 6 | + "width": 4, | |
| 7 | + "height": 2, | |
| 8 | + "appliedLocation": "ALL", | |
| 9 | + "showRuler": true, | |
| 10 | + "showGrid": true, | |
| 11 | + "elements": [ | |
| 12 | + { | |
| 13 | + "id": "el-1773998886036-34sylni", | |
| 14 | + "type": "TEXT_STATIC", | |
| 15 | + "x": 104, | |
| 16 | + "y": 16, | |
| 17 | + "width": 120, | |
| 18 | + "height": 24, | |
| 19 | + "rotation": "horizontal", | |
| 20 | + "border": "none", | |
| 21 | + "config": { | |
| 22 | + "text": "文本", | |
| 23 | + "fontFamily": "Arial", | |
| 24 | + "fontSize": 14, | |
| 25 | + "fontWeight": "normal", | |
| 26 | + "textAlign": "center" | |
| 27 | + } | |
| 28 | + }, | |
| 29 | + { | |
| 30 | + "id": "el-1773998909568-4jjwdx7", | |
| 31 | + "type": "TEXT_PRODUCT", | |
| 32 | + "x": 96, | |
| 33 | + "y": 128, | |
| 34 | + "width": 120, | |
| 35 | + "height": 24, | |
| 36 | + "rotation": "horizontal", | |
| 37 | + "border": "none", | |
| 38 | + "config": { | |
| 39 | + "text": "商品名", | |
| 40 | + "fontFamily": "Arial", | |
| 41 | + "fontSize": 14, | |
| 42 | + "fontWeight": "normal", | |
| 43 | + "textAlign": "left" | |
| 44 | + } | |
| 45 | + }, | |
| 46 | + { | |
| 47 | + "id": "el-1773998913096-cgabpx1", | |
| 48 | + "type": "TEXT_STATIC", | |
| 49 | + "x": 88, | |
| 50 | + "y": 152, | |
| 51 | + "width": 120, | |
| 52 | + "height": 24, | |
| 53 | + "rotation": "horizontal", | |
| 54 | + "border": "none", | |
| 55 | + "config": { | |
| 56 | + "text": "文本", | |
| 57 | + "fontFamily": "Arial", | |
| 58 | + "fontSize": 14, | |
| 59 | + "fontWeight": "normal", | |
| 60 | + "textAlign": "left" | |
| 61 | + } | |
| 62 | + }, | |
| 63 | + { | |
| 64 | + "id": "el-1773999052674-uzocw1j", | |
| 65 | + "type": "QRCODE", | |
| 66 | + "x": 128, | |
| 67 | + "y": 40, | |
| 68 | + "width": 80, | |
| 69 | + "height": 80, | |
| 70 | + "rotation": "horizontal", | |
| 71 | + "border": "none", | |
| 72 | + "config": { | |
| 73 | + "data": "12341千问请问抛弃我", | |
| 74 | + "errorLevel": "M" | |
| 75 | + } | |
| 76 | + }, | |
| 77 | + { | |
| 78 | + "id": "el-1773999078958-5tgoru7", | |
| 79 | + "type": "BARCODE", | |
| 80 | + "x": 208, | |
| 81 | + "y": 128, | |
| 82 | + "width": 160, | |
| 83 | + "height": 48, | |
| 84 | + "rotation": "horizontal", | |
| 85 | + "border": "none", | |
| 86 | + "config": { | |
| 87 | + "barcodeType": "CODE128", | |
| 88 | + "data": "14124151231", | |
| 89 | + "showText": true, | |
| 90 | + "orientation": "horizontal" | |
| 91 | + } | |
| 92 | + } | |
| 93 | + ] | |
| 94 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/templates/testPrintTemplate.ts
0 → 100644
| 1 | +import testTemplateJson from './test.json' | |
| 2 | +import { adaptSystemLabelTemplate } from '../systemTemplateAdapter' | |
| 3 | +import type { LabelTemplateData, StructuredLabelTemplate, SystemLabelTemplate } from '../types/printer' | |
| 4 | + | |
| 5 | +export const TEST_PRINT_SYSTEM_TEMPLATE = testTemplateJson as SystemLabelTemplate | |
| 6 | + | |
| 7 | +export const TEST_PRINT_TEMPLATE_DATA: LabelTemplateData = { | |
| 8 | + productName: '商品名', | |
| 9 | + product: '商品名', | |
| 10 | +} | |
| 11 | + | |
| 12 | +export function createTestPrintTemplate ( | |
| 13 | + dpi = 203, | |
| 14 | + printQty = 1, | |
| 15 | + data: LabelTemplateData = TEST_PRINT_TEMPLATE_DATA | |
| 16 | +): StructuredLabelTemplate { | |
| 17 | + return adaptSystemLabelTemplate(TEST_PRINT_SYSTEM_TEMPLATE, data, { | |
| 18 | + dpi, | |
| 19 | + printQty, | |
| 20 | + }) | |
| 21 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/testPrintCanvas.ts
0 → 100644
| 1 | +import type { RawImageDataSource } from './types/printer' | |
| 2 | + | |
| 3 | +export function getTestPrintCanvasSize (maxWidthDots = 384): { width: number, height: number } { | |
| 4 | + const width = Math.max(256, Math.round(maxWidthDots || 384) - (Math.round(maxWidthDots || 384) % 8)) | |
| 5 | + const height = Math.max(220, Math.round(width * 0.62)) | |
| 6 | + return { width, height } | |
| 7 | +} | |
| 8 | + | |
| 9 | +function drawRoundedRect ( | |
| 10 | + ctx: UniNamespace.CanvasContext, | |
| 11 | + x: number, | |
| 12 | + y: number, | |
| 13 | + width: number, | |
| 14 | + height: number, | |
| 15 | + radius: number | |
| 16 | +) { | |
| 17 | + const r = Math.max(0, Math.min(radius, Math.min(width, height) / 2)) | |
| 18 | + ctx.beginPath() | |
| 19 | + ctx.moveTo(x + r, y) | |
| 20 | + ctx.lineTo(x + width - r, y) | |
| 21 | + ctx.arcTo(x + width, y, x + width, y + r, r) | |
| 22 | + ctx.lineTo(x + width, y + height - r) | |
| 23 | + ctx.arcTo(x + width, y + height, x + width - r, y + height, r) | |
| 24 | + ctx.lineTo(x + r, y + height) | |
| 25 | + ctx.arcTo(x, y + height, x, y + height - r, r) | |
| 26 | + ctx.lineTo(x, y + r) | |
| 27 | + ctx.arcTo(x, y, x + r, y, r) | |
| 28 | + ctx.closePath() | |
| 29 | +} | |
| 30 | + | |
| 31 | +export async function renderTestPrintCanvasImageData ( | |
| 32 | + canvasId: string, | |
| 33 | + componentInstance: any, | |
| 34 | + size: { width: number, height: number } | |
| 35 | +): Promise<RawImageDataSource> { | |
| 36 | + const width = Math.max(8, size.width - (size.width % 8)) | |
| 37 | + const height = Math.max(1, size.height) | |
| 38 | + const scale = width / 800 | |
| 39 | + const ctx = uni.createCanvasContext(canvasId, componentInstance) | |
| 40 | + | |
| 41 | + ctx.setFillStyle('#ffffff') | |
| 42 | + ctx.fillRect(0, 0, width, height) | |
| 43 | + | |
| 44 | + drawRoundedRect(ctx, 10 * scale, 10 * scale, width - 20 * scale, height - 20 * scale, 18 * scale) | |
| 45 | + ctx.setLineWidth(Math.max(2, 3 * scale)) | |
| 46 | + ctx.setStrokeStyle('#111111') | |
| 47 | + ctx.stroke() | |
| 48 | + | |
| 49 | + ctx.setFillStyle('#111111') | |
| 50 | + ctx.setFontSize(Math.max(22, Math.round(width * 0.045))) | |
| 51 | + ctx.fillText('IN USE FOOD LABEL', 26 * scale, 44 * scale) | |
| 52 | + | |
| 53 | + ctx.setLineWidth(Math.max(1, 2 * scale)) | |
| 54 | + ctx.beginPath() | |
| 55 | + ctx.moveTo(24 * scale, 62 * scale) | |
| 56 | + ctx.lineTo(width - 24 * scale, 62 * scale) | |
| 57 | + ctx.stroke() | |
| 58 | + | |
| 59 | + ctx.setFontSize(Math.max(18, Math.round(width * 0.036))) | |
| 60 | + ctx.fillText('Product', 26 * scale, 96 * scale) | |
| 61 | + ctx.setFontSize(Math.max(20, Math.round(width * 0.04))) | |
| 62 | + ctx.fillText('Grilled Chicken Breast', 26 * scale, 128 * scale) | |
| 63 | + | |
| 64 | + ctx.setFontSize(Math.max(17, Math.round(width * 0.034))) | |
| 65 | + ctx.fillText('Prepared Protein / Hot Prep', 26 * scale, 162 * scale) | |
| 66 | + ctx.fillText('Prepared: 03/20/2026 10:30 AM', 26 * scale, 220 * scale) | |
| 67 | + ctx.fillText('Use By : 03/23/2026 10:30 AM', 26 * scale, 268 * scale) | |
| 68 | + ctx.fillText('Shelf Life: 72 Hours', 26 * scale, 316 * scale) | |
| 69 | + | |
| 70 | + const rightX = 540 * scale | |
| 71 | + ctx.setFontSize(Math.max(18, Math.round(width * 0.035))) | |
| 72 | + ctx.fillText('Label ID', rightX, 96 * scale) | |
| 73 | + ctx.setFontSize(Math.max(16, Math.round(width * 0.03))) | |
| 74 | + ctx.fillText('TEST-260320-001', rightX, 128 * scale) | |
| 75 | + ctx.fillText('Status: ACTIVE', rightX, 238 * scale) | |
| 76 | + ctx.fillText('Station: LINE 1', rightX, 286 * scale) | |
| 77 | + ctx.fillText('User: TEST USER', rightX, 334 * scale) | |
| 78 | + | |
| 79 | + const qrX = rightX | |
| 80 | + const qrY = 148 * scale | |
| 81 | + const qrSize = Math.round(120 * scale) | |
| 82 | + ctx.setLineWidth(Math.max(2, 3 * scale)) | |
| 83 | + ctx.strokeRect(qrX, qrY, qrSize, qrSize) | |
| 84 | + ctx.strokeRect(qrX + 10 * scale, qrY + 10 * scale, 20 * scale, 20 * scale) | |
| 85 | + ctx.strokeRect(qrX + qrSize - 30 * scale, qrY + 10 * scale, 20 * scale, 20 * scale) | |
| 86 | + ctx.strokeRect(qrX + 10 * scale, qrY + qrSize - 30 * scale, 20 * scale, 20 * scale) | |
| 87 | + ctx.fillRect(qrX + 42 * scale, qrY + 18 * scale, 12 * scale, 12 * scale) | |
| 88 | + ctx.fillRect(qrX + 58 * scale, qrY + 34 * scale, 8 * scale, 8 * scale) | |
| 89 | + ctx.fillRect(qrX + 46 * scale, qrY + 54 * scale, 10 * scale, 10 * scale) | |
| 90 | + ctx.fillRect(qrX + 68 * scale, qrY + 68 * scale, 12 * scale, 12 * scale) | |
| 91 | + ctx.fillRect(qrX + 50 * scale, qrY + 84 * scale, 8 * scale, 8 * scale) | |
| 92 | + | |
| 93 | + await new Promise<void>((resolve) => { | |
| 94 | + ctx.draw(false, () => resolve()) | |
| 95 | + }) | |
| 96 | + | |
| 97 | + await new Promise<void>((resolve) => setTimeout(resolve, 30)) | |
| 98 | + | |
| 99 | + return new Promise((resolve, reject) => { | |
| 100 | + uni.canvasGetImageData({ | |
| 101 | + canvasId, | |
| 102 | + x: 0, | |
| 103 | + y: 0, | |
| 104 | + width, | |
| 105 | + height, | |
| 106 | + success: (res: any) => { | |
| 107 | + resolve({ | |
| 108 | + width, | |
| 109 | + height, | |
| 110 | + data: res.data, | |
| 111 | + }) | |
| 112 | + }, | |
| 113 | + fail: (err: any) => { | |
| 114 | + reject(new Error(err?.errMsg || 'CANVAS_GET_IMAGE_DATA_FAILED')) | |
| 115 | + }, | |
| 116 | + }, componentInstance) | |
| 117 | + }) | |
| 118 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/tscLabelBuilder.ts
| ... | ... | @@ -3,6 +3,7 @@ |
| 3 | 3 | * 使用 UTF-8 编码。对于纯英文/ASCII 内容,UTF-8 与 GB18030 字节一致,佳博打印机可正常打印。 |
| 4 | 4 | * 若需中文等非 ASCII,可考虑运行时动态加载 tsc.js(仅 APP 端)。 |
| 5 | 5 | */ |
| 6 | +import type { MonochromeImageData, PrintImageOptions, StructuredTscTemplate } from './types/printer' | |
| 6 | 7 | |
| 7 | 8 | /** 将字符串转为 UTF-8 字节数组(不依赖 TextEncoder) */ |
| 8 | 9 | function stringToUtf8Bytes (str: string): number[] { |
| ... | ... | @@ -35,10 +36,30 @@ function addCommandBytes (out: number[], str: string) { |
| 35 | 36 | for (let i = 0; i < bytes.length; i++) out.push(bytes[i]) |
| 36 | 37 | } |
| 37 | 38 | |
| 39 | +function addTscLine (out: number[], str: string) { | |
| 40 | + addCommandBytes(out, str + '\r\n') | |
| 41 | +} | |
| 42 | + | |
| 38 | 43 | function escapeTscString (s: string): string { |
| 39 | 44 | return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') |
| 40 | 45 | } |
| 41 | 46 | |
| 47 | +function normalizeTscBarcodeType (value?: string): string { | |
| 48 | + const key = String(value || 'CODE128').trim().toUpperCase() | |
| 49 | + const map: Record<string, string> = { | |
| 50 | + CODE128: '128', | |
| 51 | + CODE39: '39', | |
| 52 | + EAN13: 'EAN13', | |
| 53 | + EAN8: 'EAN8', | |
| 54 | + UPCA: 'UPCA', | |
| 55 | + UPCE: 'UPCE', | |
| 56 | + CODABAR: 'CODA', | |
| 57 | + ITF14: 'ITF14', | |
| 58 | + ITF: 'ITF', | |
| 59 | + } | |
| 60 | + return map[key] || '128' | |
| 61 | +} | |
| 62 | + | |
| 42 | 63 | /** |
| 43 | 64 | * 构建标签打印指令字节数组(TSC) |
| 44 | 65 | * 与官方 Demo createLabel 格式一致(SIZE 100x30, GAP 0) |
| ... | ... | @@ -93,38 +114,124 @@ export function buildTscLabel (options: { |
| 93 | 114 | */ |
| 94 | 115 | export function buildTestTscLabel (): number[] { |
| 95 | 116 | const out: number[] = [] |
| 96 | - const add = (s: string) => addCommandBytes(out, s + '\r\n') | |
| 97 | - let y = 10 | |
| 117 | + const add = (s: string) => addTscLine(out, s) | |
| 98 | 118 | |
| 99 | - add('SIZE 100 mm,70 mm') | |
| 119 | + add('SIZE 100 mm,65 mm') | |
| 100 | 120 | add('GAP 0 mm,0 mm') |
| 101 | 121 | add('CLS') |
| 122 | + add('BOX 20,20,780,500,3') | |
| 123 | + add('BAR 20,90,760,3') | |
| 124 | + add('BAR 20,215,760,2') | |
| 125 | + add('BAR 20,335,760,2') | |
| 126 | + add('BAR 520,90,2,410') | |
| 127 | + | |
| 128 | + add('TEXT 180,42,"TSS24.BF2",0,1,1,"IN USE FOOD LABEL"') | |
| 129 | + | |
| 130 | + add('TEXT 45,115,"TSS24.BF2",0,1,1,"Product"') | |
| 131 | + add('TEXT 45,150,"TSS24.BF2",0,1,1,"Grilled Chicken Breast"') | |
| 132 | + add('TEXT 45,182,"TSS24.BF2",0,1,1,"Prepared Protein / Hot Prep"') | |
| 133 | + | |
| 134 | + add('TEXT 45,240,"TSS24.BF2",0,1,1,"Prepared"') | |
| 135 | + add('TEXT 45,275,"TSS24.BF2",0,1,1,"03/20/2026 10:30 AM"') | |
| 136 | + add('TEXT 45,360,"TSS24.BF2",0,1,1,"Use By"') | |
| 137 | + add('TEXT 45,395,"TSS24.BF2",0,1,1,"03/23/2026 10:30 AM"') | |
| 138 | + add('TEXT 45,430,"TSS24.BF2",0,1,1,"Shelf Life 72 Hours"') | |
| 139 | + | |
| 140 | + add('TEXT 545,118,"TSS24.BF2",0,1,1,"Label ID"') | |
| 141 | + add('TEXT 545,152,"TSS24.BF2",0,1,1,"TEST-260320-001"') | |
| 142 | + add('TEXT 545,330,"TSS24.BF2",0,1,1,"Status: ACTIVE"') | |
| 143 | + add('TEXT 545,365,"TSS24.BF2",0,1,1,"Station: LINE 1"') | |
| 144 | + add('TEXT 545,400,"TSS24.BF2",0,1,1,"User: TEST USER"') | |
| 145 | + add('QRCODE 555,190,L,4,A,0,"TEST-260320-001"') | |
| 102 | 146 | |
| 103 | - add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"NUTRITION FACTS"`) | |
| 104 | - y += 28 | |
| 105 | - add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"Grilled Chicken Breast"`) | |
| 106 | - y += 28 | |
| 107 | - add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"Serving Size 1 piece (100g)"`) | |
| 108 | - y += 32 | |
| 109 | - add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"Calories 165"`) | |
| 110 | - y += 28 | |
| 111 | - add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"Total Fat 4g"`) | |
| 112 | - y += 24 | |
| 113 | - add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"Saturated Fat 1g"`) | |
| 114 | - y += 24 | |
| 115 | - add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"Cholesterol 85mg"`) | |
| 116 | - y += 24 | |
| 117 | - add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"Sodium 75mg"`) | |
| 118 | - y += 24 | |
| 119 | - add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"Total Carb 0g"`) | |
| 120 | - y += 24 | |
| 121 | - add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"Protein 31g"`) | |
| 122 | - y += 28 | |
| 123 | - add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"Exp: 01/17/25 ID: TEST-251227"`) | |
| 124 | - y += 35 | |
| 125 | - add(`QRCODE 50,${y},L,4,A,0,"TEST-251227-001"`) | |
| 126 | 147 | add('PRINT 1,1') |
| 127 | 148 | add('FEED 1') |
| 128 | 149 | |
| 129 | 150 | return out |
| 130 | 151 | } |
| 152 | + | |
| 153 | +export function buildTscTemplateLabel (template: StructuredTscTemplate): number[] { | |
| 154 | + const out: number[] = [] | |
| 155 | + const add = (s: string) => addTscLine(out, s) | |
| 156 | + | |
| 157 | + add(`SIZE ${template.widthMm} mm,${template.heightMm} mm`) | |
| 158 | + add(`GAP ${template.gapMm || 0} mm,0 mm`) | |
| 159 | + if (template.density != null) add(`DENSITY ${template.density}`) | |
| 160 | + if (template.speed != null) add(`SPEED ${template.speed}`) | |
| 161 | + add('CLS') | |
| 162 | + | |
| 163 | + template.items.forEach((item) => { | |
| 164 | + if (item.type === 'box') { | |
| 165 | + const right = item.x + item.width | |
| 166 | + const bottom = item.y + item.height | |
| 167 | + add(`BOX ${item.x},${item.y},${right},${bottom},${item.lineWidth || 1}`) | |
| 168 | + return | |
| 169 | + } | |
| 170 | + if (item.type === 'bar') { | |
| 171 | + add(`BAR ${item.x},${item.y},${item.width},${item.height}`) | |
| 172 | + return | |
| 173 | + } | |
| 174 | + if (item.type === 'qrcode') { | |
| 175 | + add(`QRCODE ${item.x},${item.y},${item.level || 'L'},${item.cellWidth || 4},${item.mode || 'A'},0,"${escapeTscString(item.value)}"`) | |
| 176 | + return | |
| 177 | + } | |
| 178 | + if (item.type === 'barcode') { | |
| 179 | + add(`BARCODE ${item.x},${item.y},"${normalizeTscBarcodeType(item.symbology)}",${Math.max(20, Math.round(item.height || 80))},${item.readable === false ? 0 : 1},${item.rotation || 0},${Math.max(1, Math.round(item.narrow || 2))},${Math.max(2, Math.round(item.wide || 2))},"${escapeTscString(item.value)}"`) | |
| 180 | + return | |
| 181 | + } | |
| 182 | + add(`TEXT ${item.x},${item.y},"${item.font || 'TSS24.BF2'}",${item.rotation || 0},${item.xScale || 1},${item.yScale || 1},"${escapeTscString(item.text)}"`) | |
| 183 | + }) | |
| 184 | + | |
| 185 | + add(`PRINT 1,${Math.max(1, Math.round(template.printQty || 1))}`) | |
| 186 | + return out | |
| 187 | +} | |
| 188 | + | |
| 189 | +function roundMm (value: number): string { | |
| 190 | + return (Math.round(value * 10) / 10).toFixed(1) | |
| 191 | +} | |
| 192 | + | |
| 193 | +function pixelsToTscBitmapBytes (image: MonochromeImageData): number[] { | |
| 194 | + const bytes: number[] = [] | |
| 195 | + const bytesPerRow = image.width / 8 | |
| 196 | + for (let y = 0; y < image.height; y++) { | |
| 197 | + for (let byteIndex = 0; byteIndex < bytesPerRow; byteIndex++) { | |
| 198 | + let value = 0 | |
| 199 | + for (let bit = 0; bit < 8; bit++) { | |
| 200 | + const x = byteIndex * 8 + bit | |
| 201 | + const pixel = image.pixels[y * image.width + x] | |
| 202 | + const isWhite = pixel ? 0 : 1 | |
| 203 | + value |= isWhite << (7 - bit) | |
| 204 | + } | |
| 205 | + bytes.push(value & 0xff) | |
| 206 | + } | |
| 207 | + } | |
| 208 | + return bytes | |
| 209 | +} | |
| 210 | + | |
| 211 | +export function buildTscImageLabel ( | |
| 212 | + image: MonochromeImageData, | |
| 213 | + options: PrintImageOptions = {}, | |
| 214 | + dpi = 203 | |
| 215 | +): number[] { | |
| 216 | + const out: number[] = [] | |
| 217 | + const add = (s: string) => addCommandBytes(out, s + '\r\n') | |
| 218 | + const widthMm = options.widthMm || (image.width * 25.4 / dpi) | |
| 219 | + const heightMm = options.heightMm || (image.height * 25.4 / dpi) | |
| 220 | + const x = Math.max(0, Math.round(options.x || 0)) | |
| 221 | + const y = Math.max(0, Math.round(options.y || 0)) | |
| 222 | + const printQty = Math.max(1, Math.round(options.printQty || 1)) | |
| 223 | + const bytesPerRow = image.width / 8 | |
| 224 | + const bitmapBytes = pixelsToTscBitmapBytes(image) | |
| 225 | + | |
| 226 | + add(`SIZE ${roundMm(widthMm)} mm,${roundMm(heightMm)} mm`) | |
| 227 | + add('GAP 0 mm,0 mm') | |
| 228 | + add('DENSITY 14') | |
| 229 | + add('SPEED 5') | |
| 230 | + add('CLS') | |
| 231 | + add(`BITMAP ${x},${y},${bytesPerRow},${image.height},0,`) | |
| 232 | + for (let i = 0; i < bitmapBytes.length; i++) out.push(bitmapBytes[i]) | |
| 233 | + out.push(0x0d, 0x0a) | |
| 234 | + add(`PRINT 1,${printQty}`) | |
| 235 | + | |
| 236 | + return out | |
| 237 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts
| ... | ... | @@ -20,6 +20,179 @@ export interface LabelPrintPayload { |
| 20 | 20 | extraLine?: string |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | +export interface PrintImageOptions { | |
| 24 | + printQty?: number | |
| 25 | + threshold?: number | |
| 26 | + maxWidthDots?: number | |
| 27 | + targetWidthDots?: number | |
| 28 | + targetHeightDots?: number | |
| 29 | + widthMm?: number | |
| 30 | + heightMm?: number | |
| 31 | + x?: number | |
| 32 | + y?: number | |
| 33 | +} | |
| 34 | + | |
| 35 | +export interface MonochromeImageData { | |
| 36 | + width: number | |
| 37 | + height: number | |
| 38 | + pixels: number[] // 1 = black, 0 = white | |
| 39 | +} | |
| 40 | + | |
| 41 | +export interface RawImageDataSource { | |
| 42 | + width: number | |
| 43 | + height: number | |
| 44 | + data: ArrayLike<number> | |
| 45 | +} | |
| 46 | + | |
| 47 | +export type LabelTemplateValue = string | number | |
| 48 | +export type LabelTemplateData = Record<string, LabelTemplateValue> | |
| 49 | +export type PrinterTemplateUnit = 'inch' | 'mm' | 'cm' | 'px' | |
| 50 | +export type SystemTemplateRotation = 'horizontal' | 'vertical' | |
| 51 | +export type SystemTemplateTextAlign = 'left' | 'center' | 'right' | |
| 52 | + | |
| 53 | +export interface SystemTemplateElementBase { | |
| 54 | + id: string | |
| 55 | + type: string | |
| 56 | + x: number | |
| 57 | + y: number | |
| 58 | + width: number | |
| 59 | + height: number | |
| 60 | + rotation?: SystemTemplateRotation | |
| 61 | + border?: string | |
| 62 | + config: Record<string, any> | |
| 63 | +} | |
| 64 | + | |
| 65 | +export interface SystemLabelTemplate { | |
| 66 | + id: string | |
| 67 | + name: string | |
| 68 | + labelType?: string | |
| 69 | + unit?: PrinterTemplateUnit | |
| 70 | + width: number | |
| 71 | + height: number | |
| 72 | + appliedLocation?: string | |
| 73 | + showRuler?: boolean | |
| 74 | + showGrid?: boolean | |
| 75 | + elements: SystemTemplateElementBase[] | |
| 76 | +} | |
| 77 | + | |
| 78 | +export interface TscTemplateTextItem { | |
| 79 | + type: 'text' | |
| 80 | + x: number | |
| 81 | + y: number | |
| 82 | + text: string | |
| 83 | + font?: string | |
| 84 | + rotation?: number | |
| 85 | + xScale?: number | |
| 86 | + yScale?: number | |
| 87 | +} | |
| 88 | + | |
| 89 | +export interface TscTemplateBoxItem { | |
| 90 | + type: 'box' | |
| 91 | + x: number | |
| 92 | + y: number | |
| 93 | + width: number | |
| 94 | + height: number | |
| 95 | + lineWidth?: number | |
| 96 | +} | |
| 97 | + | |
| 98 | +export interface TscTemplateBarItem { | |
| 99 | + type: 'bar' | |
| 100 | + x: number | |
| 101 | + y: number | |
| 102 | + width: number | |
| 103 | + height: number | |
| 104 | +} | |
| 105 | + | |
| 106 | +export interface TscTemplateQrCodeItem { | |
| 107 | + type: 'qrcode' | |
| 108 | + x: number | |
| 109 | + y: number | |
| 110 | + value: string | |
| 111 | + level?: 'L' | 'M' | 'Q' | 'H' | |
| 112 | + cellWidth?: number | |
| 113 | + mode?: 'A' | 'M' | |
| 114 | +} | |
| 115 | + | |
| 116 | +export interface TscTemplateBarcodeItem { | |
| 117 | + type: 'barcode' | |
| 118 | + x: number | |
| 119 | + y: number | |
| 120 | + value: string | |
| 121 | + symbology?: string | |
| 122 | + height?: number | |
| 123 | + readable?: boolean | |
| 124 | + rotation?: number | |
| 125 | + narrow?: number | |
| 126 | + wide?: number | |
| 127 | +} | |
| 128 | + | |
| 129 | +export type TscTemplateItem = | |
| 130 | + | TscTemplateTextItem | |
| 131 | + | TscTemplateBoxItem | |
| 132 | + | TscTemplateBarItem | |
| 133 | + | TscTemplateQrCodeItem | |
| 134 | + | TscTemplateBarcodeItem | |
| 135 | + | |
| 136 | +export interface StructuredTscTemplate { | |
| 137 | + widthMm: number | |
| 138 | + heightMm: number | |
| 139 | + gapMm?: number | |
| 140 | + density?: number | |
| 141 | + speed?: number | |
| 142 | + printQty?: number | |
| 143 | + items: TscTemplateItem[] | |
| 144 | +} | |
| 145 | + | |
| 146 | +export interface EscTemplateTextItem { | |
| 147 | + type: 'text' | |
| 148 | + text: string | |
| 149 | + align?: 0 | 1 | 2 | |
| 150 | + bold?: boolean | |
| 151 | + widthScale?: number | |
| 152 | + heightScale?: number | |
| 153 | +} | |
| 154 | + | |
| 155 | +export interface EscTemplateRuleItem { | |
| 156 | + type: 'rule' | |
| 157 | + width?: number | |
| 158 | +} | |
| 159 | + | |
| 160 | +export interface EscTemplateQrCodeItem { | |
| 161 | + type: 'qrcode' | |
| 162 | + value: string | |
| 163 | + align?: 0 | 1 | 2 | |
| 164 | + size?: number | |
| 165 | + level?: 'L' | 'M' | 'Q' | 'H' | |
| 166 | +} | |
| 167 | + | |
| 168 | +export interface EscTemplateBarcodeItem { | |
| 169 | + type: 'barcode' | |
| 170 | + value: string | |
| 171 | + align?: 0 | 1 | 2 | |
| 172 | + symbology?: string | |
| 173 | + height?: number | |
| 174 | + width?: number | |
| 175 | + showText?: boolean | |
| 176 | +} | |
| 177 | + | |
| 178 | +export type EscTemplateItem = | |
| 179 | + | EscTemplateTextItem | |
| 180 | + | EscTemplateRuleItem | |
| 181 | + | EscTemplateQrCodeItem | |
| 182 | + | EscTemplateBarcodeItem | |
| 183 | + | |
| 184 | +export interface StructuredEscTemplate { | |
| 185 | + printQty?: number | |
| 186 | + feedLines?: number | |
| 187 | + items: EscTemplateItem[] | |
| 188 | +} | |
| 189 | + | |
| 190 | +export interface StructuredLabelTemplate { | |
| 191 | + key: string | |
| 192 | + tsc?: StructuredTscTemplate | |
| 193 | + esc?: StructuredEscTemplate | |
| 194 | +} | |
| 195 | + | |
| 23 | 196 | export interface PrinterDriver { |
| 24 | 197 | key: string |
| 25 | 198 | brand: string |
| ... | ... | @@ -28,6 +201,8 @@ export interface PrinterDriver { |
| 28 | 201 | protocol: PrinterProtocol |
| 29 | 202 | preferredConnection?: ActiveBtDeviceType |
| 30 | 203 | preferredBleMtu?: number |
| 204 | + imageMaxWidthDots?: number | |
| 205 | + imageDpi?: number | |
| 31 | 206 | keywords: string[] |
| 32 | 207 | matches: (device: PrinterCandidate) => number |
| 33 | 208 | resolveConnectionType: (device: PrinterCandidate) => ActiveBtDeviceType | ... | ... |