package com.foodlabel.nativeprinter.template; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Typeface; import android.util.Base64; import org.json.JSONArray; import org.json.JSONObject; import java.io.ByteArrayOutputStream; import java.nio.charset.Charset; import java.nio.charset.CharsetEncoder; import java.nio.charset.StandardCharsets; import java.util.regex.Matcher; import java.util.regex.Pattern; public final class NativeTemplateCommandBuilder { private static final double DESIGN_DPI = 96.0; /** * 与 JS 光栅路径 clearTopRasterRows 等效:热敏头可印区相对模板顶边常有一小段空白, * 全部为 0 时顶部中文位图/TEXT 易被裁切;略下移与整页光栅观感一致。 */ private static final int LABEL_TOP_MARGIN_DOTS = 18; private static final int TEXT_PADDING_DOTS = 6; private static final int DEFAULT_THRESHOLD = 180; private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{\\s*([\\w.-]+)\\s*\\}\\}"); private NativeTemplateCommandBuilder() { } public static byte[] build(String templateJson, String dataJson, int dpi, int printQty) throws Exception { JSONObject template = new JSONObject(templateJson); JSONObject data = (dataJson == null || dataJson.trim().isEmpty()) ? new JSONObject() : new JSONObject(dataJson); return buildWithStats(template, data, dpi, printQty).bytes; } public static byte[] build(JSONObject template, JSONObject data, int dpi, int printQty) throws Exception { return buildWithStats(template, data, dpi, printQty).bytes; } public static BuildResult buildWithStats(String templateJson, String dataJson, int dpi, int printQty) throws Exception { JSONObject template = new JSONObject(templateJson); JSONObject data = (dataJson == null || dataJson.trim().isEmpty()) ? new JSONObject() : new JSONObject(dataJson); return buildWithStats(template, data, dpi, printQty); } public static BuildResult buildWithStats(JSONObject template, JSONObject data, int dpi, int printQty) throws Exception { ByteArrayOutputStream out = new ByteArrayOutputStream(); int nativeTextCount = 0; int rasterTextCount = 0; int qrCodeCount = 0; int barcodeCount = 0; int imagePatchCount = 0; int lineCount = 0; String unit = getString(template, "unit", "inch"); double widthMm = round1(toMillimeter(getDouble(template, "width", 0), unit)); double heightMm = round1(toMillimeter(getDouble(template, "height", 0), unit)); double pageWidthPx = widthMm / 25.4 * DESIGN_DPI; addLine(out, "SIZE " + formatMm(widthMm) + " mm," + formatMm(heightMm) + " mm"); addLine(out, "GAP 0 mm,0 mm"); addLine(out, "CODEPAGE 1252"); addLine(out, "DENSITY 14"); addLine(out, "SPEED 5"); addLine(out, "CLS"); JSONArray elements = template.optJSONArray("elements"); if (elements != null) { for (int i = 0; i < elements.length(); i++) { JSONObject element = elements.optJSONObject(i); if (element == null) continue; JSONObject config = element.optJSONObject("config"); if (config == null) config = new JSONObject(); String type = getString(element, "type", "").toUpperCase(); if (type.startsWith("TEXT_")) { String text = resolveElementText(type, config, data); if (text.isEmpty()) continue; String align = resolveElementAlign(element, config, pageWidthPx); if (shouldRasterizeText(text, type)) { rasterTextCount++; BitmapPatch patch = createTextPatch(element, type, config, text, dpi, align); writeBitmapPatch(out, patch); } else { nativeTextCount++; int scale = resolveTextScale(getDouble(config, "fontSize", 14), dpi); if ("TEXT_PRICE".equals(type)) { // 价格行与普通文案保持接近视觉粗细,避免看起来偏“粗黑”。 scale = Math.max(1, scale - 1); } int x = resolveTextX(align, getDouble(element, "x", 0), getDouble(element, "width", 0), dpi, text, scale); int y = yDots(getDouble(element, "y", 0), dpi); int rotation = "vertical".equalsIgnoreCase(getString(element, "rotation", "horizontal")) ? 90 : 0; addLine(out, "TEXT " + x + "," + y + ",\"TSS24.BF2\"," + rotation + "," + scale + "," + scale + ",\"" + escapeTscString(text) + "\""); } continue; } if ("QRCODE".equals(type)) { String sourceLike = getString(config, "src", getString(config, "data", getString(config, "url", ""))); if (isImageLikeSource(sourceLike)) { BitmapPatch patch = createImagePatch(element, config, dpi, sourceLike); if (patch != null) { imagePatchCount++; writeBitmapPatch(out, patch); } continue; } String value = resolveElementDataValue(type, config, data); if (value.isEmpty()) continue; if (isImageLikeSource(value)) { BitmapPatch patch = createImagePatch(element, config, dpi, value); if (patch != null) { imagePatchCount++; writeBitmapPatch(out, patch); } continue; } qrCodeCount++; String level = normalizeQrLevel(getString(config, "errorLevel", "M")); int x = pxToDots(getDouble(element, "x", 0), dpi); int y = yDots(getDouble(element, "y", 0), dpi); int size = resolveQrModuleSize(getDouble(element, "width", 0), getDouble(element, "height", 0), dpi, value, level); addLine(out, "QRCODE " + x + "," + y + "," + level + "," + size + ",A,0,\"" + escapeTscString(value) + "\""); continue; } if ("BARCODE".equals(type)) { String value = resolveElementDataValue(type, config, data); if (value.isEmpty()) continue; barcodeCount++; int x = pxToDots(getDouble(element, "x", 0), dpi); int y = yDots(getDouble(element, "y", 0), dpi); int height = Math.max(20, pxToDots(getDouble(element, "height", 0), dpi)); int readable = getBoolean(config, "showText", true) ? 1 : 0; String orientation = getString(config, "orientation", getString(element, "rotation", "horizontal")); if ("vertical".equalsIgnoreCase(orientation)) { BitmapPatch patch = createVerticalBarcodePatch(element, config, dpi, value); if (patch != null) { imagePatchCount++; writeBitmapPatch(out, patch); } continue; } int rotation = "vertical".equalsIgnoreCase(orientation) ? 90 : 0; int narrow = clamp(getDouble(element, "width", 0) / Math.max(40.0, value.length() * 6.0), 1, 4); int wide = clamp(getDouble(element, "width", 0) / Math.max(24.0, value.length() * 3.0), 2, 6); String symbology = normalizeBarcodeType(getString(config, "barcodeType", "CODE128")); addLine(out, "BARCODE " + x + "," + y + ",\"" + symbology + "\"," + height + "," + readable + "," + rotation + "," + narrow + "," + wide + ",\"" + escapeTscString(value) + "\""); continue; } if ("IMAGE".equals(type)) { BitmapPatch patch = createImagePatch(element, config, dpi); if (patch != null) { imagePatchCount++; writeBitmapPatch(out, patch); } continue; } if ("BLANK".equals(type) && "line".equalsIgnoreCase(getString(element, "border", ""))) { lineCount++; int x = pxToDots(getDouble(element, "x", 0), dpi); int y = yDots(getDouble(element, "y", 0), dpi); int width = Math.max(1, pxToDots(getDouble(element, "width", 0), dpi)); int height = Math.max(1, pxToDots(getDouble(element, "height", 1), dpi)); addLine(out, "BAR " + x + "," + y + "," + width + "," + height); } } } addLine(out, "PRINT 1," + Math.max(1, printQty)); return new BuildResult( out.toByteArray(), nativeTextCount, rasterTextCount, qrCodeCount, barcodeCount, imagePatchCount, lineCount, elements == null ? 0 : elements.length() ); } private static String resolveElementText(String type, JSONObject config, JSONObject data) { String configText = getString(config, "text", ""); boolean hasText = !configText.isEmpty(); if ("TEXT_PRICE".equals(type)) { String bindingKey = resolveBindingKey(type, config); String boundValue = resolveTemplateValue(data, bindingKey); String raw = !boundValue.isEmpty() ? boundValue : (hasText ? applyTemplateData(configText, data) : ""); if (raw.isEmpty()) return ""; String prefix = getString(config, "prefix", ""); String suffix = getString(config, "suffix", ""); int decimal = (int) getDouble(config, "decimal", -1); if (decimal >= 0) { try { double value = Double.parseDouble(raw); raw = String.format(java.util.Locale.US, "%1$." + decimal + "f", value); } catch (Exception ignored) { } } raw = trimLeadingCurrencyIfPrefixed(raw, prefix); return normalizePriceCurrencySymbol(prefix + raw + suffix); } if (hasText && "TEXT_STATIC".equals(type)) { return applyTemplateData(configText, data); } if (hasText && configText.contains("{{")) { return applyTemplateData(configText, data); } String bindingKey = resolveBindingKey(type, config); String boundValue = resolveTemplateValue(data, bindingKey); if (!boundValue.isEmpty()) return boundValue; return hasText ? applyTemplateData(configText, data) : ""; } private static String resolveElementDataValue(String type, JSONObject config, JSONObject data) { String raw = getString(config, "data", getString(config, "value", "")); if (!raw.isEmpty()) return applyTemplateData(raw, data); return resolveTemplateValue(data, resolveBindingKey(type, config)); } private static String resolveBindingKey(String type, JSONObject config) { String[] keys = new String[]{"dataKey", "field", "bindField", "key", "valueKey"}; for (String key : keys) { String value = getString(config, key, ""); if (!value.isEmpty()) return value; } switch (type) { case "TEXT_PRODUCT": return "productName"; case "TEXT_LABEL_ID": return "labelId"; case "TEXT_CATEGORY": return "category"; case "TEXT_PRICE": return "price"; case "TEXT_DATE": return "date"; case "TEXT_TIME": return "time"; case "QRCODE": return "qrCode"; case "BARCODE": return "barcode"; default: String pureType = type.replace("TEXT_", "").replace("FIELD_", "").replace("VALUE_", ""); return pureType.isEmpty() ? "" : toCamelCase(pureType); } } private static String resolveTemplateValue(JSONObject data, String key) { if (key == null || key.isEmpty()) return ""; String[] candidates; switch (key) { case "productName": candidates = new String[]{"productName", "product"}; break; case "product": candidates = new String[]{"product", "productName"}; break; case "qrCode": candidates = new String[]{"qrCode", "labelId", "barcode"}; break; case "barcode": candidates = new String[]{"barcode", "labelId", "qrCode"}; break; default: candidates = new String[]{key}; } for (String candidate : candidates) { Object value = data.opt(candidate); if (value != null) return String.valueOf(value); } return ""; } private static String applyTemplateData(String text, JSONObject data) { Matcher matcher = PLACEHOLDER_PATTERN.matcher(text == null ? "" : text); StringBuffer buffer = new StringBuffer(); while (matcher.find()) { String key = matcher.group(1); Object value = data.opt(key); matcher.appendReplacement(buffer, Matcher.quoteReplacement(value == null ? "" : String.valueOf(value))); } matcher.appendTail(buffer); return buffer.toString(); } private static String toCamelCase(String value) { String[] parts = value.toLowerCase().split("[_\\s-]+"); StringBuilder builder = new StringBuilder(); for (int i = 0; i < parts.length; i++) { if (parts[i].isEmpty()) continue; if (builder.length() == 0) { builder.append(parts[i]); } else { builder.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1)); } } return builder.toString(); } private static String resolveElementAlign(JSONObject element, JSONObject config, double pageWidthPx) { String align = getString(config, "textAlign", "").toLowerCase(); if ("left".equals(align) || "center".equals(align) || "right".equals(align)) return align; double centerX = getDouble(element, "x", 0) + getDouble(element, "width", 0) / 2.0; if (centerX <= pageWidthPx * 0.33) return "left"; if (centerX >= pageWidthPx * 0.67) return "right"; return "center"; } private static boolean shouldRasterizeText(String text, String type) { if (text == null || text.isEmpty()) return false; if ("TEXT_PRICE".equals(type) && isSimplePriceLikeText(text)) { // 价格行优先走原生 TEXT:避免位图二值化导致的糊边/左侧杂点。 return false; } for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); if (c < 32 || c > 126) { return true; } } CharsetEncoder encoder = getPrinterEncoder(); if (encoder == null) return true; try { return !encoder.canEncode(text); } catch (Exception e) { return true; } } private static boolean isSimplePriceLikeText(String text) { String s = text == null ? "" : text.trim(); if (s.isEmpty()) return false; // 允许货币符号 + 数字/小数点/逗号/空格,统一按原生字体输出。 return s.matches("^[¥¥$€£]?\\s*[-+]?\\d+(?:[.,]\\d{1,2})?\\s*$"); } private static String normalizePriceCurrencySymbol(String value) { if (value == null || value.isEmpty()) return ""; return value.replace('¥', '¥'); } private static String trimLeadingCurrencyIfPrefixed(String raw, String prefix) { if (raw == null || raw.isEmpty()) return ""; String p = prefix == null ? "" : prefix.trim(); if (p.isEmpty()) return raw; char c = p.charAt(0); if (c != '¥' && c != '¥' && c != '$' && c != '€' && c != '£') return raw; String s = raw.trim(); while (!s.isEmpty()) { char ch = s.charAt(0); if (ch == '¥' || ch == '¥' || ch == '$' || ch == '€' || ch == '£') { s = s.substring(1).trim(); continue; } break; } return s; } private static BitmapPatch createTextPatch(JSONObject element, String type, JSONObject config, String text, int dpi, String align) { int contentWidth = Math.max(8, pxToDots(getDouble(element, "width", 0), dpi)); Paint paint = new Paint(); paint.setAntiAlias(true); paint.setDither(true); paint.setSubpixelText(true); paint.setColor(Color.BLACK); int fontSizeDots = Math.max(14, pxToDots(getDouble(config, "fontSize", 14), dpi)); paint.setTextSize(fontSizeDots); /** 不再对 TEXT_PRICE 强制加粗:fakeBold + 粗体会糊边、measureText 偏窄,右对齐时左侧易出现杂点 */ boolean bold = "bold".equalsIgnoreCase(getString(config, "fontWeight", "")); paint.setFakeBoldText(bold); paint.setTypeface(bold ? Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) : Typeface.SANS_SERIF); if (text.indexOf('\n') < 0) { int singleLineWidth = (int) Math.ceil(paint.measureText(text)) + 8; contentWidth = Math.max(contentWidth, singleLineWidth); } java.util.List lines = splitTextLines(text, paint, Math.max(8, contentWidth)); Paint.FontMetrics metrics = paint.getFontMetrics(); int lineHeight = Math.max(fontSizeDots + 2, (int) Math.ceil(Math.abs(metrics.top) + Math.abs(metrics.bottom) + 2)); int totalHeight = lines.size() * lineHeight; float maxLineWidth = 0; for (String line : lines) { maxLineWidth = Math.max(maxLineWidth, paint.measureText(line)); } int horizontalPadding = TEXT_PADDING_DOTS * 2; int verticalPadding = TEXT_PADDING_DOTS * 2; int width = ensureMultipleOf8(Math.max(contentWidth + horizontalPadding * 2, (int) Math.ceil(maxLineWidth) + horizontalPadding * 2 + 4)); int height = Math.max(16, Math.max(pxToDots(getDouble(element, "height", 0), dpi) + verticalPadding * 2, totalHeight + verticalPadding * 2 + 4)); Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); canvas.drawColor(Color.WHITE); int topOffset = "TEXT_PRICE".equals(type) ? Math.max(verticalPadding, (height - totalHeight) / 2) : verticalPadding; int drawableWidth = width - horizontalPadding * 2; for (int i = 0; i < lines.size(); i++) { String line = lines.get(i); float lineWidth = paint.measureText(line); float drawX = horizontalPadding; if ("center".equals(align)) { drawX = horizontalPadding + Math.max(0, (drawableWidth - lineWidth) / 2f); } else if ("right".equals(align)) { /** ¥ 等字符 measureText 常偏窄,右对齐时真实笔画会略凸向左,易在位图左缘挤出杂点 */ float w = lineWidth; if ("TEXT_PRICE".equals(type)) { w += Math.max(2f, paint.getTextSize() * 0.12f); } drawX = horizontalPadding + Math.max(0, drawableWidth - w); } float baseline = topOffset + i * lineHeight - metrics.top; canvas.drawText(line, drawX, baseline, paint); } BitmapPatch patch = new BitmapPatch(Math.max(0, pxToDots(getDouble(element, "x", 0), dpi) - horizontalPadding), Math.max(0, yDots(getDouble(element, "y", 0), dpi) - verticalPadding), invertMonochrome(bitmapToMonochrome(bitmap, DEFAULT_THRESHOLD))); bitmap.recycle(); return patch; } private static BitmapPatch createImagePatch(JSONObject element, JSONObject config, int dpi) { return createImagePatch(element, config, dpi, null); } private static BitmapPatch createVerticalBarcodePatch(JSONObject element, JSONObject config, int dpi, String value) { int width = ensureMultipleOf8(Math.max(8, pxToDots(getDouble(element, "width", 0), dpi))); int height = Math.max(12, pxToDots(getDouble(element, "height", 0), dpi)); Bitmap outputBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(outputBitmap); canvas.drawColor(Color.WHITE); int pad = 2; boolean showText = getBoolean(config, "showText", true); int textBandWidth = (showText && value != null && !value.isEmpty()) ? Math.max(10, (int) Math.round(width * 0.18)) : 0; int barAreaWidth = Math.max(8, width - textBandWidth - pad * 2); int innerHeight = Math.max(10, height - pad * 2); int[] modules = barcodeModulesFromValue(value); Paint barPaint = new Paint(); barPaint.setAntiAlias(false); barPaint.setColor(Color.BLACK); if (modules.length > 0) { double moduleH = (double) innerHeight / (double) modules.length; double cursorY = pad; for (int i = 0; i < modules.length; i++) { if (modules[i] == 1) { float top = (float) cursorY; float bottom = (float) (cursorY + Math.max(0.7, moduleH * 0.86)); canvas.drawRect(pad, top, pad + barAreaWidth, bottom, barPaint); } cursorY += moduleH; } } if (showText && value != null && !value.isEmpty() && textBandWidth > 0) { Paint txt = new Paint(); txt.setAntiAlias(true); txt.setColor(Color.BLACK); int font = Math.max(9, Math.min(11, (int) Math.floor(textBandWidth * 0.75))); txt.setTextSize(font); txt.setTextAlign(Paint.Align.CENTER); float cx = width - textBandWidth / 2f; float cy = height / 2f; canvas.save(); // 竖排文本按模板端习惯:从下到上 canvas.rotate(-90f, cx, cy); Paint.FontMetrics fm = txt.getFontMetrics(); float baseline = cy - (fm.ascent + fm.descent) / 2f; canvas.drawText(value, cx, baseline, txt); canvas.restore(); } BitmapPatch patch = new BitmapPatch( pxToDots(getDouble(element, "x", 0), dpi), yDots(getDouble(element, "y", 0), dpi), bitmapToMonochrome(outputBitmap, (int) getDouble(config, "threshold", DEFAULT_THRESHOLD)) ); outputBitmap.recycle(); return patch; } private static int[] barcodeModulesFromValue(String value) { String s = value == null ? "" : value.trim(); if (s.isEmpty()) return new int[0]; java.util.ArrayList m = new java.util.ArrayList<>(); // quiet + start int[] start = new int[]{1, 0, 1, 0, 1, 0, 1, 0}; for (int v : start) m.add(v); for (int i = 0; i < s.length(); i++) { int code = s.charAt(i) & 0xFF; int key = (code ^ (i * 13) ^ (s.length() * 7)) & 0x1F; for (int b = 4; b >= 0; b--) { m.add((key >> b) & 1); } m.add(0); } int[] stop = new int[]{1, 0, 1, 1, 0, 1, 0, 1}; for (int v : stop) m.add(v); int[] out = new int[m.size()]; for (int i = 0; i < m.size(); i++) out[i] = m.get(i); return out; } private static BitmapPatch createImagePatch(JSONObject element, JSONObject config, int dpi, String sourceOverride) { String source = sourceOverride; if (source == null || source.isEmpty()) { source = getString(config, "src", getString(config, "data", getString(config, "url", ""))); } if (source.isEmpty()) return null; Bitmap sourceBitmap = decodeBitmap(source); if (sourceBitmap == null) return null; int width = ensureMultipleOf8(Math.max(8, pxToDots(getDouble(element, "width", 0), dpi))); int height = Math.max(8, pxToDots(getDouble(element, "height", 0), dpi)); Bitmap outputBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(outputBitmap); canvas.drawColor(Color.WHITE); int sourceWidth = sourceBitmap.getWidth(); int sourceHeight = sourceBitmap.getHeight(); String scaleMode = getString(config, "scaleMode", "contain").toLowerCase(); int targetWidth = width; int targetHeight = height; int targetLeft = 0; int targetTop = 0; if (sourceWidth > 0 && sourceHeight > 0 && !"fill".equals(scaleMode)) { double ratio = "cover".equals(scaleMode) ? Math.max((double) width / sourceWidth, (double) height / sourceHeight) : Math.min((double) width / sourceWidth, (double) height / sourceHeight); targetWidth = Math.max(1, (int) Math.round(sourceWidth * ratio)); targetHeight = Math.max(1, (int) Math.round(sourceHeight * ratio)); targetLeft = (width - targetWidth) / 2; targetTop = (height - targetHeight) / 2; } Bitmap scaledBitmap = Bitmap.createScaledBitmap(sourceBitmap, targetWidth, targetHeight, true); Paint paint = new Paint(); paint.setAntiAlias(true); paint.setFilterBitmap(true); canvas.drawBitmap(scaledBitmap, targetLeft, targetTop, paint); BitmapPatch patch = new BitmapPatch(pxToDots(getDouble(element, "x", 0), dpi), yDots(getDouble(element, "y", 0), dpi), bitmapToMonochrome(outputBitmap, (int) getDouble(config, "threshold", DEFAULT_THRESHOLD))); scaledBitmap.recycle(); sourceBitmap.recycle(); outputBitmap.recycle(); return patch; } private static boolean isImageLikeSource(String source) { if (source == null) return false; String s = source.trim().toLowerCase(); if (s.isEmpty()) return false; if (s.startsWith("data:image/")) return true; if (s.startsWith("file://")) return true; if (s.startsWith("/picture/") || s.startsWith("picture/")) return true; if (s.startsWith("/static/") || s.startsWith("static/")) return true; if (s.matches("^[a-z]:[\\\\/].*")) return true; return s.matches(".*\\.(png|jpe?g|gif|webp|bmp)(\\?.*)?$"); } private static Bitmap decodeBitmap(String source) { try { if (source.startsWith("data:image/")) { int comma = source.indexOf(','); String payload = comma >= 0 ? source.substring(comma + 1) : ""; if (payload.isEmpty()) return null; byte[] bytes = Base64.decode(payload, Base64.DEFAULT); return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); } if (source.matches("^[A-Za-z0-9+/=\\r\\n]+$") && source.length() > 128) { byte[] bytes = Base64.decode(source, Base64.DEFAULT); return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); } String path = source.startsWith("file://") ? source.substring(7) : source; return BitmapFactory.decodeFile(path); } catch (Exception e) { return null; } } private static java.util.List splitTextLines(String text, Paint paint, int maxWidth) { java.util.List lines = new java.util.ArrayList<>(); String[] rawLines = (text == null ? "" : text.replace("\r", "")).split("\n"); for (String segment : rawLines) { if (segment.isEmpty()) { lines.add(""); continue; } StringBuilder current = new StringBuilder(); for (int i = 0; i < segment.length(); i++) { char c = segment.charAt(i); String candidate = current.toString() + c; if (current.length() > 0 && paint.measureText(candidate) > maxWidth) { lines.add(current.toString()); current.setLength(0); current.append(c); } else { current.append(c); } } if (current.length() > 0) lines.add(current.toString()); } if (lines.isEmpty()) lines.add(""); return lines; } private static void writeBitmapPatch(ByteArrayOutputStream out, BitmapPatch patch) { int bytesPerRow = patch.image.width / 8; addLine(out, "BITMAP " + patch.x + "," + patch.y + "," + bytesPerRow + "," + patch.image.height + ",0,"); for (int y = 0; y < patch.image.height; y++) { for (int byteIndex = 0; byteIndex < bytesPerRow; byteIndex++) { int value = 0; for (int bit = 0; bit < 8; bit++) { int x = byteIndex * 8 + bit; int pixel = patch.image.pixels[y * patch.image.width + x]; if (pixel == 1) value |= (1 << (7 - bit)); } out.write(value & 0xFF); } } out.write('\r'); out.write('\n'); } private static MonochromeImage bitmapToMonochrome(Bitmap bitmap, int threshold) { int bitmapWidth = bitmap.getWidth(); int bitmapHeight = bitmap.getHeight(); int width = ensureMultipleOf8(bitmapWidth); int[] pixels = new int[width * bitmapHeight]; for (int y = 0; y < bitmapHeight; y++) { for (int x = 0; x < width; x++) { if (x >= bitmapWidth) { pixels[y * width + x] = 0; continue; } int color = bitmap.getPixel(x, y); int alpha = (color >>> 24) & 0xFF; int red = (color >>> 16) & 0xFF; int green = (color >>> 8) & 0xFF; int blue = color & 0xFF; double gray = red * 0.299 + green * 0.587 + blue * 0.114; pixels[y * width + x] = alpha <= 10 || gray > threshold ? 0 : 1; } } return new MonochromeImage(width, bitmapHeight, pixels); } private static MonochromeImage invertMonochrome(MonochromeImage image) { if (image == null || image.pixels == null) return image; int[] pixels = new int[image.pixels.length]; for (int i = 0; i < image.pixels.length; i++) { pixels[i] = image.pixels[i] == 1 ? 0 : 1; } return new MonochromeImage(image.width, image.height, pixels); } private static void addLine(ByteArrayOutputStream out, String line) { byte[] bytes = line.getBytes(getPrinterCharset()); out.write(bytes, 0, bytes.length); out.write('\r'); out.write('\n'); } private static Charset getPrinterCharset() { try { return Charset.forName("windows-1252"); } catch (Throwable first) { try { return Charset.forName("Cp1252"); } catch (Throwable second) { return StandardCharsets.ISO_8859_1; } } } private static CharsetEncoder getPrinterEncoder() { try { return getPrinterCharset().newEncoder(); } catch (Throwable first) { try { return StandardCharsets.ISO_8859_1.newEncoder(); } catch (Throwable second) { return null; } } } private static String escapeTscString(String value) { return value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\""); } private static String normalizeBarcodeType(String value) { String key = value == null ? "CODE128" : value.trim().toUpperCase(); switch (key) { case "CODE39": return "39"; case "EAN13": return "EAN13"; case "EAN8": return "EAN8"; case "UPCA": return "UPCA"; case "UPCE": return "UPCE"; case "CODABAR": return "CODA"; case "ITF14": return "ITF14"; case "ITF": return "ITF"; default: return "128"; } } private static String normalizeQrLevel(String value) { String key = value == null ? "M" : value.trim().toUpperCase(); if ("L".equals(key) || "M".equals(key) || "Q".equals(key) || "H".equals(key)) return key; return "M"; } private static int resolveQrModuleSize(double widthPx, double heightPx, int dpi, String value, String level) { int targetDots = Math.max(24, Math.min(pxToDots(widthPx, dpi), pxToDots(heightPx, dpi))); int moduleCount = Math.max(21, estimateQrModuleCount(value, level)); return clamp(Math.floorDiv(targetDots, moduleCount), 3, 12); } private static int estimateQrModuleCount(String value, String level) { int length = Math.max(1, value == null ? 0 : value.length()); int[] capacities; switch (level) { case "L": capacities = new int[]{17, 32, 53, 78, 106, 134, 154, 192, 230, 271}; break; case "Q": capacities = new int[]{11, 20, 32, 46, 60, 74, 86, 108, 130, 151}; break; case "H": capacities = new int[]{7, 14, 24, 34, 44, 58, 64, 84, 98, 119}; break; default: capacities = new int[]{14, 26, 42, 62, 84, 106, 122, 152, 180, 213}; } int version = capacities.length; for (int i = 0; i < capacities.length; i++) { if (length <= capacities[i]) { version = i + 1; break; } } return 21 + (version - 1) * 4; } private static int resolveTextScale(double fontSizePx, int dpi) { int targetDots = Math.max(12, (int) Math.round(fontSizePx * dpi / DESIGN_DPI)); return clamp(targetDots / 24.0, 1, 7); } private static int resolveTextX(String align, double xPx, double widthPx, int dpi, String text, int scale) { int left = pxToDots(xPx, dpi); if ("left".equals(align)) return left; int boxWidth = pxToDots(widthPx, dpi); int fontDots = Math.max(24, scale * 24); int textWidth = estimateTextWidthDots(text, fontDots); if ("center".equals(align)) return Math.max(0, left + Math.max(0, boxWidth - textWidth) / 2); return Math.max(0, left + Math.max(0, boxWidth - textWidth)); } private static int estimateTextWidthDots(String text, int fontDots) { double total = 0; for (int i = 0; i < text.length(); i++) { total += text.charAt(i) > 255 ? fontDots : fontDots * 0.6; } return (int) Math.round(total); } private static int clamp(double value, int min, int max) { return Math.max(min, Math.min(max, (int) Math.round(value))); } private static int ensureMultipleOf8(int value) { int safe = Math.max(8, value); return safe % 8 == 0 ? safe : safe + (8 - safe % 8); } private static int pxToDots(double value, int dpi) { return Math.max(0, (int) Math.round(value * dpi / DESIGN_DPI)); } /** 模板 y(px)→ 点阵 y,并加上与光栅路径一致的上边距,减轻顶部裁切 */ private static int yDots(double yPx, int dpi) { return Math.max(0, pxToDots(yPx, dpi) + LABEL_TOP_MARGIN_DOTS); } private static double toMillimeter(double value, String unit) { if ("mm".equalsIgnoreCase(unit)) return value; if ("cm".equalsIgnoreCase(unit)) return value * 10; if ("px".equalsIgnoreCase(unit)) return value / DESIGN_DPI * 25.4; return value * 25.4; } private static double round1(double value) { return Math.round(value * 10.0) / 10.0; } private static String formatMm(double value) { return String.format(java.util.Locale.US, "%.1f", value); } private static String getString(JSONObject json, String key, String fallback) { Object value = json.opt(key); return value == null ? fallback : String.valueOf(value); } private static double getDouble(JSONObject json, String key, double fallback) { try { Object value = json.opt(key); if (value == null) return fallback; if (value instanceof Number) return ((Number) value).doubleValue(); return Double.parseDouble(String.valueOf(value)); } catch (Exception e) { return fallback; } } private static boolean getBoolean(JSONObject json, String key, boolean fallback) { try { Object value = json.opt(key); if (value == null) return fallback; if (value instanceof Boolean) return (Boolean) value; return Boolean.parseBoolean(String.valueOf(value)); } catch (Exception e) { return fallback; } } private static final class BitmapPatch { final int x; final int y; final MonochromeImage image; BitmapPatch(int x, int y, MonochromeImage image) { this.x = x; this.y = y; this.image = image; } } private static final class MonochromeImage { final int width; final int height; final int[] pixels; MonochromeImage(int width, int height, int[] pixels) { this.width = width; this.height = height; this.pixels = pixels; } } public static final class BuildResult { public final byte[] bytes; public final int nativeTextCount; public final int rasterTextCount; public final int qrCodeCount; public final int barcodeCount; public final int imagePatchCount; public final int lineCount; public final int elementCount; public BuildResult(byte[] bytes, int nativeTextCount, int rasterTextCount, int qrCodeCount, int barcodeCount, int imagePatchCount, int lineCount, int elementCount) { this.bytes = bytes == null ? new byte[0] : bytes; this.nativeTextCount = nativeTextCount; this.rasterTextCount = rasterTextCount; this.qrCodeCount = qrCodeCount; this.barcodeCount = barcodeCount; this.imagePatchCount = imagePatchCount; this.lineCount = lineCount; this.elementCount = elementCount; } } }