Uniapp调用济强打印机JLP352 hiprint 打印方案

24 阅读12分钟

JLP352 hiprint 打印方案调研与开发记录

日期: 2026-10-08
设备: 济强 ULT113B(常称 JLP352)
连接方式: Android PDA → Bluetooth SPP
标签: 80 × 50 mm,200 dpi

目标与约束

保留 hiprint 模板编辑和布局;模板数据、打印数据按接口返回的 JSON 使用。目标是缩短打印等待时间,同时不影响复杂元素的兼容性。当前原型在 hiprint-test.html。

当前打印链路

验证页包含两条路径:

  • 位图路径: hiprint JSON 模板 + 打印数据 → hiprint HTML → PNG → 黑白光栅 → CPCL EG → SPP。
  • 原生 CPCL 实验路径: 同一 hiprint 模板和数据 → 遍历模板 JSON → 文本转 TEXT,线框转 LINE / BOX,Logo 单独转局部 EG → SPP。

打印按钮已先后通过两个模拟异步接口获取打印数据和模板,再调用 getHtml(printData)。目前模拟接口仍是页面内的 Promise 和定时器,不是真实后端 API。

调研与开发过程

阶段结果
初始验证CPCL 文字和小位图可打印;整页 PNG 任务曾卡住,显示准备发送约 124 KB。
SPP 调整蓝牙查询移出界面同步路径,改为分块异步写入。把块增至 8 KB 本身没有明显提速。
EG 数据裁剪按模板有墨点的区域裁剪空白行和左右空白列。实测载荷由约 124,402 字节降至 35,727 字节,SPP 耗时由 27.6 秒降至 9.6 秒。
CG 压缩尝试PackBits 十六进制候选打印成条纹,判定该编码与设备 CG 格式不兼容,未用于正常路径。后来测试原始二进制 CG,小图可打印出黑方块,但完整模板和性能仍未确认。
hiprint 模板假数据按用户提供的 80×50 mm 模板结构和假数据构造测试模板,并模拟异步获取模板和数据。
原生 CPCL 实验初版能打印,但字体、行距和规格文本与 hiprint 预览不一致。之后尝试调整字体与坐标换算,真机打印仍未达到预期。

SPP 耗时是从开始连接/发送到 flush 的测量,不等于机械出纸时间。位图路径提速效果有真机数据;原生 CPCL 路径的等待时间尚未得到可靠测量。

当前结论

  • 已验证有效的优化是裁剪位图空白区域;8 KB 分块和 SPEED 3 没有解决主要传输耗时。
  • 当前普通 EG 位图路径是已知可工作的回退方式,复杂模板兼容性较好,但整页栅格数据仍需传输。
  • 原生 CPCL 有望减少文字标签的传输量,但目前字体、坐标、文本换行和设备兼容性尚未对齐,不能视为可替代方案。
  • CG 仍是实验方向:曾使用的 PackBits 格式不兼容,原始二进制小图可打印,但缺少对完整标签格式及速度收益的验证。

推荐的公共打印入口

后续可把打印逻辑封装为 PDA 公共模块,页面仅传模板、数据和模式:

printService.print({
  templateJson,
  data,
  printerName: 'ULT113B',
  mode: 'auto'
});

建议 auto 模式对已验证的文本、线条、矩形、条码使用原生 CPCL;图片及尚未支持的元素回退到局部或整页位图。模板预览继续由 hiprint 渲染。该公共模块目前尚未从测试页抽取实现。

内网依赖处理

CDN 在内网不可用,部署前应将实际需要的依赖固定版本并放入前端资源包。基础项是 vue-plugin-hiprint 及其所需的 jQuery/CSS;html2canvas 用于位图渲染,canvg 用于 SVG 转换,JsBarcode / bwip-js 用于对应条码元素。nzh、颜色选择器、Socket.IO 和 jsPDF 是否保留,取决于实际模板功能和打印方式;应先逐项验证,再移除未使用依赖。

下一步验证标准

  1. 用原生 CPCL 按钮重新打印同一模板,核对品名、型号、规格、数量、批次号、日期及加工厂商的字体、行距和位置。
  2. 确认规格文本是否换行完整,Logo 与线框是否与 hiprint 预览对应,内容是否全部位于 80×50 mm 标签范围内。
  3. 分别记录位图路径与原生 CPCL 路径的 SPP 耗时和实际出纸耗时。
  4. 原生路径通过中文、溢出和复杂元素测试后,再决定是否抽取为公共 printService;未通过时继续保留 EG 回退。

主代码块

<html lang="zh-CN">
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
	<title>hiprint 图片预览测试</title>
	<link rel="stylesheet" type="text/css" media="all" href="https://cdn.jsdelivr.net/npm/vue-plugin-hiprint@0.0.60/dist/print-lock.css">
	<style>
		* { box-sizing: border-box; }
		body { margin: 0; padding: 16px; color: #252a31; background: #f3f5f7; font-family: Arial, sans-serif; }
		button { min-height: 44px; margin: 0 8px 8px 0; padding: 0 16px; border: 0; border-radius: 4px; color: #fff; background: #176b5b; font-size: 15px; }
		button:disabled { opacity: .55; }
		.status { margin: 12px 0; color: #5e6872; font-size: 13px; }
		.preview { overflow: auto; min-height: 100px; padding: 12px; background: #fff; border: 1px solid #d9dee3; }
		.preview img { display: block; max-width: 100%; height: auto; margin-top: 12px; border: 1px solid #d9dee3; }
		.render-source { position: fixed; left: -10000px; top: 0; }
		.note { margin-top: 12px; color: #7b4a00; font-size: 12px; line-height: 1.5; }
	</style>
	<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.1/dist/jquery.min.js"></script>
	<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.5/dist/JsBarcode.all.min.js"></script>
	<script src="https://cdn.jsdelivr.net/npm/bwip-js@4.5.1/dist/bwip-js.js"></script>
	<script src="https://cdn.jsdelivr.net/npm/nzh@1.0.14/dist/nzh.min.js"></script>
	<script src="https://cdn.jsdelivr.net/npm/@claviska/jquery-minicolors@2.3.6/jquery.minicolors.min.js"></script>
	<script src="https://cdn.jsdelivr.net/npm/socket.io-client@4.5.1/dist/socket.io.min.js"></script>
	<script src="https://cdn.jsdelivr.net/npm/canvg@3.0.10/lib/umd.js"></script>
	<script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js"></script>
	<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
	<script src="https://cdn.jsdelivr.net/npm/vue-plugin-hiprint@0.0.60/dist/vue-plugin-hiprint.js"></script>
	<script>
		var hiprintPlugin = window['vue-plugin-hiprint'];
		if (hiprintPlugin && typeof hiprintPlugin.disAutoConnect === 'function') hiprintPlugin.disAutoConnect();
	</script>
</head>
<body>
	<button id="textPrintButton" type="button">测试 CPCL 文字打印</button>
	<button id="smallPrintButton" type="button">测试小位图</button>
	<button id="renderButton" type="button">生成 PNG 并打印位图</button>
	<button id="nativeRenderButton" type="button">hiprint 模板原生 CPCL 实验打印</button>
	<div id="status" class="status">等待生成</div>
	<div id="preview" class="preview"></div>
	<div class="note">80 × 50 mm / 200 dpi</div>
	<div id="renderSource" class="render-source"></div>
	<script>
		var mockApiPrintData = {
			BOENum: '',
			BatchNo: '5XDAZAHZIKR52L',
			ExpirationDate: '2027-04-08 00:00:00',
			GrossWeight: '',
			LoadTypeDesc: '',
			LoadTypeId: '01.01.051.02.061',
			LoadTypeName: 'TS805抛光片',
			LoadTypeNameM: 'TS805抛光片',
			Model: '1',
			PONum: '4300462819',
			Producer: '2',
			ProductionDate: '2026-10-08 00:00:00',
			QRCode: '||20Pcs||2026/10/8 0:00:00|2027/4/8 0:00:00|5XDAZAHZIKR52L',
			QuantityInfo: '20Pcs',
			Remark: '',
			SNNum: '',
			ShelfLife: '6',
			Specification: '141.52±0.1*80.44±0.1*0.54±0.02mm,R2±0.1,C0.15±0.05,红色',
			SpecificationM: '141.52±0.1*80.44±0.1*0.54±0.02mm,R2±0.1,C0.15±0.05,红色',
			SupplierCode: '',
			SupplierId: null,
			WWCGDDCode: 'CGDD2608284772'
		};

		function fetchMockPrintData() {
			return new Promise(function(resolve) {
				setTimeout(function() {
					resolve(Object.assign({}, mockApiPrintData));
				}, 400);
			});
		}

		var sppUuid = '00001101-0000-1000-8000-00805F9B34FB';
		var printerName = 'ULT113B';
		var labelWidthDots = 630;
		var labelHeightDots = 394;
		var printPocVersion = 'POC-20261008-R15';

		function mmToPt(value) {
			return value * 72 / 25.4;
		}

		function addTextMm(panel, options) {
			['left', 'top', 'width', 'height'].forEach(function(key) {
				options[key] = mmToPt(options[key]);
			});
			panel.addPrintText({ options: options });
		}

		function createMockTemplateResponse() {
			function textElement(title, field, left, top, width, fontSize) {
				return {
					options: {
						left: left,
						top: top,
						width: width,
						height: 9.75,
						fontSize: fontSize || 9,
						textAlign: 'left',
						textContentVerticalAlign: 'middle',
						hideTitle: !!field,
						title: title,
						field: field,
						testData: field || null
					},
					printElementType: { title: '文本', type: 'text' }
				};
			}
			function rule(type, left, top, width, height, borderWidth) {
				return {
					options: { left: left, top: top, width: width, height: height, borderWidth: borderWidth || '0.75' },
					printElementType: { title: type === 'rect' ? '矩形' : '横线', type: type }
				};
			}
			var printElements = [
				{
					options: { left: 55.5, top: 13.5, width: 9, height: 123 },
					printElementType: { title: '竖线', type: 'vline' }
				},
				rule('rect', 4.5, 13.5, 217.5, 123),
				textElement('品名', null, 10.5, 15, 43.5),
				textElement('文本', 'LoadTypeNameM', 60, 15, 157.5, 8.25),
				rule('hline', 4.5, 27, 217.5, 9),
				textElement('型号', null, 10.5, 31.5, 43.5),
				textElement('TTT', 'Model', 60, 31.5, 157.5, 8.25),
				rule('hline', 4.5, 43.5, 217.5, 9),
				textElement('规格', null, 9, 48, 43.5),
				textElement('文本', 'SpecificationM', 60, 48, 159, 8.25),
				rule('hline', 4.5, 61.5, 217.5, 9),
				textElement('数量', null, 9, 66, 43.5),
				textElement('文本', 'QuantityInfo', 60, 66, 159, 8.25),
				rule('hline', 4.5, 79.5, 217.5, 9),
				textElement('批次号', null, 9, 84, 43.5),
				textElement('文本', 'BatchNo', 60, 84, 159, 8.25),
				rule('hline', 4.5, 97.5, 217.5, 9),
				textElement('生产日期', null, 9, 102, 43.5),
				textElement('生产日期', 'ProductionDate', 60, 102, 157.5, 8.25),
				rule('hline', 4.5, 115.5, 217.5, 9),
				textElement('加工厂商', null, 9, 120, 43.5),
				textElement('加工厂商', 'Producer', 60, 120, 159, 8.25)
			];
			var templateJson = {
				panels: [{
					index: 0,
					name: 1,
					height: 50,
					width: 80,
					panelPageRule: 'none',
					paperHeader: 2,
					paperFooter: 210,
					paperNumberDisabled: true,
					printElements: printElements,
					paperNumberLeft: 124.5,
					paperNumberTop: 119,
					paperNumberContinue: true,
					watermarkOptions: { content: null, rotate: 0, timestamp: false, format: null },
					panelLayoutOptions: {}
				}]
			};
			return templateJson;
		}

		function fetchMockPrintTemplate() {
			return new Promise(function(resolve) {
				setTimeout(function() {
					resolve(createMockTemplateResponse());
				}, 500);
			});
		}

		function createMockTemplate(hiprint, templateData) {
			return new hiprint.PrintTemplate({ template: templateData });
		}

		function setStatus(message) {
			document.getElementById('status').textContent = message;
			console.log('[print-poc] ' + message);
		}

		function requestBluetoothPermission(callback) {
			if (!window.plus || !plus.android) {
				setStatus('当前页面没有 Android Native.js 环境,请从 PDA APK 打开。');
				return;
			}
			var osVersion = String(plus.os && plus.os.version || '');
			var versionMatch = osVersion.match(/\d+/);
			var androidMajorVersion = versionMatch ? Number(versionMatch[0]) : 0;
			if (androidMajorVersion > 0 && androidMajorVersion < 12) {
				callback();
				return;
			}
			if (!plus.android.requestPermissions) {
				setStatus('当前 App-Plus Runtime 不支持 requestPermissions;请升级 HBuilderX Runtime。');
				return;
			}
			var permission = 'android.permission.BLUETOOTH_CONNECT';
			setStatus('正在请求 Android ' + (osVersion || '12+') + ' 附近设备权限…');
			try {
				plus.android.requestPermissions([permission], function(result) {
					var granted = result && result.granted ? result.granted : [];
					var deniedPresent = result && result.deniedPresent ? result.deniedPresent : [];
					var deniedAlways = result && result.deniedAlways ? result.deniedAlways : [];
					if (granted.indexOf(permission) >= 0) callback();
					else if (deniedAlways.indexOf(permission) >= 0) setStatus('附近设备权限被永久拒绝,请到系统应用权限设置中手动开启。');
					else if (deniedPresent.indexOf(permission) >= 0) setStatus('附近设备权限未授权,允许权限后再试。');
					else setStatus('系统未返回附近设备权限授权结果:' + JSON.stringify(result || {}));
				}, function(error) {
					setStatus('requestPermissions 接口失败:' + (error.code || '') + ' ' + (error.message || error));
				});
			} catch (error) {
				setStatus('调用 requestPermissions 异常:' + (error.code || '') + ' ' + (error.message || error));
			}
		}

		function findBondedPrinter() {
			var BluetoothAdapter = plus.android.importClass('android.bluetooth.BluetoothAdapter');
			var adapter = BluetoothAdapter.getDefaultAdapter();
			if (!adapter) throw new Error('设备没有可用的蓝牙适配器');
			var bonded = adapter.getBondedDevices();
			plus.android.importClass(bonded);
			var iterator = plus.android.invoke(bonded, 'iterator');
			var names = [];
			while (plus.android.invoke(iterator, 'hasNext')) {
				var device = plus.android.invoke(iterator, 'next');
				plus.android.importClass(device);
				var name = plus.android.invoke(device, 'getName');
				names.push(name);
				if (name === printerName) return device;
			}
			throw new Error('未找到已配对设备 ' + printerName + ';当前已配对设备:' + names.join(', '));
		}

		function asciiBytes(value) {
			var javaString = plus.android.newObject('java.lang.String', value);
			return plus.android.invoke(javaString, 'getBytes', 'US-ASCII');
		}

		function gbkBytes(value) {
			var javaString = plus.android.newObject('java.lang.String', value);
			return plus.android.invoke(javaString, 'getBytes', 'GBK');
		}

		function runOnMainThread(callback) {
			setTimeout(callback, 0);
		}

		var sppBusy = false;
		var sppSocket = null;
		var sppOutput = null;
		var sppDeviceAddress = '';

		function closeSppConnection() {
			try { if (sppOutput) plus.android.invoke(sppOutput, 'close'); } catch (error) {}
			try { if (sppSocket) plus.android.invoke(sppSocket, 'close'); } catch (error) {}
			sppOutput = null;
			sppSocket = null;
			sppDeviceAddress = '';
		}

		function writeSppChunk(payload, jobName, offset, startedAt, encoding) {
			try {
				if (offset >= payload.length) {
					plus.android.invoke(sppOutput, 'flush');
					var elapsedSeconds = ((Date.now() - startedAt) / 1000).toFixed(1);
					setStatus(jobName + ' 已发送;SPP 耗时 ' + elapsedSeconds + ' 秒,请检查打印结果。');
					sppBusy = false;
					setPrintButtonsDisabled(false);
					return;
				}
				var chunk = payload.slice(offset, offset + 8192);
				plus.android.invoke(sppOutput, 'write', encoding === 'GBK' ? gbkBytes(chunk) : asciiBytes(chunk));
				var sent = offset + chunk.length;
				console.log('[print-poc] write ' + sent + ' / ' + payload.length);
				if (sent === payload.length || sent % 16384 === 0) {
					var progressUnit = encoding === 'GBK' ? '字符' : '字节';
					setStatus('SPP 已写入 ' + sent + ' / ' + payload.length + ' ' + progressUnit);
				}
				setTimeout(function() { writeSppChunk(payload, jobName, sent, startedAt, encoding); }, 0);
			} catch (error) {
				closeSppConnection();
				sppBusy = false;
				setPrintButtonsDisabled(false);
				setStatus('SPP 连接/发送失败:' + (error && error.message ? error.message : error));
			}
		}

		function sendSpp(payload, jobName, encoding) {
			requestBluetoothPermission(function() {
				if (sppBusy) {
					setStatus('已有 SPP 打印任务正在执行,请稍候。');
					return;
				}
				sppBusy = true;
				var sppStartedAt = Date.now();
				setPrintButtonsDisabled(true);
				var payloadUnit = encoding === 'GBK' ? '字符' : '字节';
				setStatus(printPocVersion + ':' + jobName + ',准备发送 ' + payload.length + ' ' + payloadUnit);
				setTimeout(function() {
					try {
						var worker = plus.android.implements('java.lang.Runnable', {
							run: function() {
								try {
									console.log('[print-poc] worker entered: ' + jobName);
									runOnMainThread(function() { setStatus(printPocVersion + ':SPP 工作线程已启动,查找已配对打印机…'); });
									var device = findBondedPrinter();
									var deviceAddress = plus.android.invoke(device, 'getAddress');
									var connected = sppSocket && sppDeviceAddress === deviceAddress;
									if (connected) {
										try { connected = !!plus.android.invoke(sppSocket, 'isConnected'); } catch (error) { connected = false; }
									}
									if (!connected) {
										closeSppConnection();
										runOnMainThread(function() { setStatus(printPocVersion + ':正在通过 SPP 连接 ' + printerName + '…'); });
										var UUID = plus.android.importClass('java.util.UUID');
										sppSocket = device.createRfcommSocketToServiceRecord(UUID.fromString(sppUuid));
										plus.android.invoke(sppSocket, 'connect');
										sppOutput = plus.android.invoke(sppSocket, 'getOutputStream');
										sppDeviceAddress = deviceAddress;
									} else {
										runOnMainThread(function() { setStatus('复用现有 SPP 连接,准备发送 ' + payload.length + ' ' + payloadUnit + '…'); });
									}
										runOnMainThread(function() { setStatus('SPP 已就绪,开始写入 ' + payload.length + ' ' + payloadUnit + '…'); });
										runOnMainThread(function() { writeSppChunk(payload, jobName, 0, sppStartedAt, encoding); });
								} catch (error) {
									var errorText = error && error.message ? error.message : String(error);
									runOnMainThread(function() {
										closeSppConnection();
										sppBusy = false;
										setPrintButtonsDisabled(false);
										setStatus('SPP 连接/发送失败:' + errorText);
									});
								}
							}
						});
						plus.android.invoke(plus.android.newObject('java.lang.Thread', worker), 'start');
					} catch (error) {
						sppBusy = false;
						setPrintButtonsDisabled(false);
						setStatus('SPP 初始化失败:' + (error.message || error));
					}
				}, 0);
			});
		}

		window.addEventListener('beforeunload', closeSppConnection);

		function setPrintButtonsDisabled(disabled) {
			['textPrintButton', 'smallPrintButton', 'renderButton', 'nativeRenderButton'].forEach(function(id) {
				document.getElementById(id).disabled = disabled;
			});
		}

		function createCpclImagePayload(canvas) {
			var raster = canvasToCpclRaster(canvas);
			var bytesPerRow = Math.ceil(canvas.width / 8);
			var rowsPerCommand = 16;
			var commands = [
				'! 0 200 200 ' + labelHeightDots + ' 1\r\n',
				'PW ' + labelWidthDots + '\r\n',
				'SPEED 3\r\n'
			];
			for (var top = 0; top < canvas.height; top += rowsPerCommand) {
				var bandBottom = Math.min(top + rowsPerCommand, canvas.height);
				var firstRow = -1;
				var lastRow = -1;
				var firstByte = bytesPerRow;
				var lastByte = -1;
				for (var row = top; row < bandBottom; row++) {
					var rowHasInk = false;
					for (var byteIndex = 0; byteIndex < bytesPerRow; byteIndex++) {
						if (raster[row * bytesPerRow + byteIndex] !== 0) {
							rowHasInk = true;
							firstByte = Math.min(firstByte, byteIndex);
							lastByte = Math.max(lastByte, byteIndex);
						}
					}
					if (rowHasInk) {
						if (firstRow < 0) firstRow = row;
						lastRow = row;
					}
				}
				if (firstRow < 0) continue;
				var bandHeight = lastRow - firstRow + 1;
				var bandWidth = lastByte - firstByte + 1;
				var hex = '';
				for (var rowIndex = firstRow; rowIndex <= lastRow; rowIndex++) {
					for (var column = firstByte; column <= lastByte; column++) {
						var index = rowIndex * bytesPerRow + column;
						hex += ('0' + raster[index].toString(16)).slice(-2);
					}
				}
				commands.push('EG ' + bandWidth + ' ' + bandHeight + ' ' + (firstByte * 8) + ' ' + firstRow + ' ' + hex + '\r\n');
			}
			commands.push('FORM\r\nPRINT\r\n');
			return commands.join('');
		}

		function templatePxToDots(value) {
			return Math.round(Number(value || 0) * 200 / 96);
		}

		function wrapCpclText(text, maxWidthDots, asciiWidth, wideWidth) {
			var lines = [];
			var line = '';
			var lineWidth = 0;
			Array.from(text).forEach(function(character) {
				var characterWidth = character.charCodeAt(0) > 255 ? wideWidth : asciiWidth;
				if (line && lineWidth + characterWidth > maxWidthDots) {
					lines.push(line);
					line = '';
					lineWidth = 0;
				}
				line += character;
				lineWidth += characterWidth;
			});
			if (line) lines.push(line);
			return lines;
		}

		function createNativeTextCommand(element, printData) {
			var options = element.options || {};
			var value = options.field ? printData[options.field] : options.title;
			if (value === null || value === undefined || value === '') return '';
			var text = String(value).replace(/[\r\n]+/g, ' ');
			var magnification = 0;
			var asciiWidth = 8;
			var wideWidth = 16;
			var lines = wrapCpclText(text, templatePxToDots(options.width), asciiWidth, wideWidth);
			var lineHeight = 10;
			return lines.map(function(line, index) {
				return 'TEXT 0 ' + magnification + ' ' + templatePxToDots(options.left) + ' ' + (templatePxToDots(options.top) + index * lineHeight) + ' ' + line + '\r\n';
			}).join('');
		}

		function createNativeRuleCommand(element) {
			var options = element.options || {};
			var type = element.printElementType && element.printElementType.type;
			var x = templatePxToDots(options.left);
			var y = templatePxToDots(options.top);
			var width = templatePxToDots(options.width);
			var height = templatePxToDots(options.height);
			var thickness = Math.max(1, templatePxToDots(parseFloat(options.borderWidth) || 0.75));
			if (type === 'rect') return 'BOX ' + x + ' ' + y + ' ' + (x + width) + ' ' + (y + height) + ' ' + thickness + '\r\n';
			if (type === 'vline') return 'LINE ' + x + ' ' + y + ' ' + x + ' ' + (y + height) + ' ' + thickness + '\r\n';
			if (type === 'hline') return 'LINE ' + x + ' ' + y + ' ' + (x + width) + ' ' + y + ' ' + thickness + '\r\n';
			return '';
		}

		function createNativeImageCommand(element) {
			return new Promise(function(resolve, reject) {
				var options = element.options || {};
				if (!options.src) {
					resolve('');
					return;
				}
				var image = new Image();
				image.onload = function() {
					try {
						var width = Math.max(1, templatePxToDots(options.width));
						var height = Math.max(1, templatePxToDots(options.height));
						var canvas = document.createElement('canvas');
						canvas.width = width;
						canvas.height = height;
						canvas.getContext('2d').drawImage(image, 0, 0, width, height);
						var raster = canvasToCpclRaster(canvas);
						var bytesPerRow = Math.ceil(width / 8);
						var hex = '';
						for (var index = 0; index < raster.length; index++) hex += ('0' + raster[index].toString(16)).slice(-2);
						resolve('EG ' + bytesPerRow + ' ' + height + ' ' + templatePxToDots(options.left) + ' ' + templatePxToDots(options.top) + ' ' + hex + '\r\n');
					} catch (error) {
						reject(error);
					}
				};
				image.onerror = function() { reject(new Error('模板图片加载失败:' + (element.name || 'image'))); };
				image.src = options.src;
			});
		}

		function createNativeCpclPayload(templateData, printData) {
			var panel = templateData && templateData.panels && templateData.panels[0];
			if (!panel) return Promise.reject(new Error('模拟模板没有 panels[0]'));
			var commands = (panel.printElements || []).map(function(element) {
				var type = element.printElementType && element.printElementType.type;
				if (type === 'text') return createNativeTextCommand(element, printData);
				if (type === 'rect' || type === 'hline' || type === 'vline') return createNativeRuleCommand(element);
				if (type === 'image') return createNativeImageCommand(element);
				return '';
			});
			return Promise.all(commands).then(function(elementCommands) {
				return '! 0 200 200 ' + labelHeightDots + ' 1\r\n' +
					'PW ' + labelWidthDots + '\r\n' +
					'SPEED 3\r\n' +
					elementCommands.join('') +
					'FORM\r\nPRINT\r\n';
			});
		}

		function createCpclTextPayload() {
			var header = '! 0 200 200 ' + labelHeightDots + ' 1\r\n' +
				'PW ' + labelWidthDots + '\r\n' +
				'SPEED 3\r\n' +
				'TEXT 4 0 24 24 KION CPCL SPP TEST\r\n' +
				'TEXT 4 0 24 72 DEVICE ULT113B\r\n' +
				'TEXT 4 0 24 120 LABEL 80x50mm 200dpi\r\n';
			return header + 'FORM\r\nPRINT\r\n';
		}

		function canvasToCpclRaster(canvas) {
			var image = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
			var bytesPerRow = Math.ceil(canvas.width / 8);
			var raster = new Uint8Array(bytesPerRow * canvas.height);
			for (var y = 0; y < canvas.height; y++) {
				for (var x = 0; x < canvas.width; x++) {
					var offset = (y * canvas.width + x) * 4;
					var luminance = image.data[offset] * 0.299 + image.data[offset + 1] * 0.587 + image.data[offset + 2] * 0.114;
					if (luminance < 160) raster[y * bytesPerRow + (x >> 3)] |= 0x80 >> (x & 7);
				}
			}
			return raster;
		}

		document.getElementById('textPrintButton').addEventListener('click', function() {
			try {
				sendSpp(createCpclTextPayload(), 'CPCL 文字测试任务');
			} catch (error) {
				setStatus('CPCL 文字指令生成失败:' + (error.message || error));
			}
		});

		document.getElementById('smallPrintButton').addEventListener('click', function() {
			try {
				var canvas = document.createElement('canvas');
				canvas.width = 64;
				canvas.height = 64;
				var context = canvas.getContext('2d');
				context.fillStyle = '#ffffff';
				context.fillRect(0, 0, 64, 64);
				context.fillStyle = '#000000';
				context.fillRect(8, 8, 48, 48);
				sendSpp(createCpclImagePayload(canvas), '64x64 小位图测试');
			} catch (error) {
				setStatus('小位图测试失败:' + (error.message || error));
			}
		});

		function renderTemplateAndPrint(useNativeCommands) {
			var status = document.getElementById('status');
			var preview = document.getElementById('preview');
			var source = document.getElementById('renderSource');
			var plugin = window['vue-plugin-hiprint'];
			var hiprint = window.hiprint || (plugin && plugin.hiprint);
			if (!window.jQuery || !window.html2canvas || !hiprint || !hiprint.PrintTemplate) {
				status.textContent = '依赖加载失败,请检查设备网络或 CDN 访问。';
				return;
			}
			status.textContent = '正在从模拟接口获取打印数据和模板…';
			setPrintButtonsDisabled(true);
			preview.innerHTML = '';
			source.innerHTML = '';
			Promise.all([fetchMockPrintData(), fetchMockPrintTemplate()]).then(function(responses) {
				var printData = responses[0];
				var templateData = responses[1];
				status.textContent = '模拟接口已返回打印数据和模板,正在渲染 hiprint…';
				try {
					var template = createMockTemplate(hiprint, templateData);
					var html = template.getHtml(printData, {});
					if (template.svg2canvas) template.svg2canvas(html);
					source.appendChild(html[0]);
					window.html2canvas(html[0], { scale: 200 / 96, backgroundColor: '#ffffff', useCORS: true }).then(function(renderedCanvas) {
						var canvas = document.createElement('canvas');
						canvas.width = labelWidthDots;
						canvas.height = labelHeightDots;
						canvas.getContext('2d').drawImage(renderedCanvas, 0, 0, canvas.width, canvas.height);
						var image = document.createElement('img');
						image.alt = 'hiprint 模板渲染图片';
						image.src = canvas.toDataURL('image/png');
						preview.appendChild(image);
						if (useNativeCommands) {
							status.textContent = '正在将模板元素转换为原生 CPCL…';
							createNativeCpclPayload(templateData, printData).then(function(payload) {
								status.textContent = '原生 CPCL 生成完成:' + payload.length + ' 个字符,准备 GBK/SPP 发送…';
								setPrintButtonsDisabled(false);
								sendSpp(payload, 'hiprint 模板原生 CPCL 实验任务', 'GBK');
							}).catch(function(error) {
								setPrintButtonsDisabled(false);
								status.textContent = '原生 CPCL 转换失败:' + (error && error.message ? error.message : error);
							});
							return;
						}
						status.textContent = 'PNG 生成成功:' + canvas.width + ' × ' + canvas.height + ' px;正在转换黑白位图…';
						var payload = createCpclImagePayload(canvas);
						status.textContent = '位图转换完成:' + payload.length + ' 字节,准备 SPP 发送…';
						setPrintButtonsDisabled(false);
						sendSpp(payload, 'CPCL 位图打印任务');
					}).catch(function(error) {
						setPrintButtonsDisabled(false);
						status.textContent = '图片生成失败:' + (error && error.message ? error.message : error);
					});
				} catch (error) {
					setPrintButtonsDisabled(false);
					status.textContent = 'hiprint 渲染失败:' + (error && error.message ? error.message : error);
				}
			}).catch(function(error) {
				setPrintButtonsDisabled(false);
				status.textContent = '模拟接口获取打印数据或模板失败:' + (error && error.message ? error.message : error);
			});
		}

		document.getElementById('renderButton').addEventListener('click', function() {
			renderTemplateAndPrint(false);
		});

		document.getElementById('nativeRenderButton').addEventListener('click', function() {
			renderTemplateAndPrint(true);
		});
	</script>
</body>
</html>