From 4b44c346eac2475021cb9baa6e31896ecad49a54 Mon Sep 17 00:00:00 2001 From: clanky1024 <288498519+clanky1024@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:29:09 -0500 Subject: [PATCH] Fix X2 raster orientation and compression --- bun.lock | 6 ++ packages/core/package.json | 8 ++- packages/core/src/device/profiles/m60.ts | 11 +++- packages/core/src/device/registry.ts | 3 +- packages/core/src/device/types.ts | 4 ++ packages/core/src/printer.ts | 1 + packages/core/src/protocol/types.ts | 1 + packages/core/src/protocol/x2/commands.ts | 18 +++++- packages/core/src/protocol/x2/protocol.ts | 4 +- packages/core/test/protocol/x2-raster.test.ts | 27 +++++++++ packages/web/src/editor/editor.tsx | 56 +++++++++++++------ 11 files changed, 113 insertions(+), 26 deletions(-) create mode 100644 packages/core/test/protocol/x2-raster.test.ts diff --git a/bun.lock b/bun.lock index 22af5ec..c833b4b 100644 --- a/bun.lock +++ b/bun.lock @@ -33,9 +33,11 @@ "version": "0.1.0", "dependencies": { "fflate": "^0.8.2", + "pako": "^3.0.1", }, "devDependencies": { "@types/bun": "latest", + "@types/pako": "^2.0.4", "typescript": "^5.7.0", }, }, @@ -310,6 +312,8 @@ "@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -474,6 +478,8 @@ "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "pako": ["pako@3.0.1", "", {}, "sha512-GupotUUI0mlhugKjUs4bjOwLt3nrehy9Ys2dxC0GtgVef5cnKggkDMmf2bq2poCCuVXopWPmqsc9VDT2iJUy+w=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], diff --git a/packages/core/package.json b/packages/core/package.json index 3244015..ade744a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,10 +15,12 @@ }, "license": "MIT", "devDependencies": { - "typescript": "^5.7.0", - "@types/bun": "latest" + "@types/bun": "latest", + "@types/pako": "^2.0.4", + "typescript": "^5.7.0" }, "dependencies": { - "fflate": "^0.8.2" + "fflate": "^0.8.2", + "pako": "^3.0.1" } } diff --git a/packages/core/src/device/profiles/m60.ts b/packages/core/src/device/profiles/m60.ts index 0cdbf02..cc84d8d 100644 --- a/packages/core/src/device/profiles/m60.ts +++ b/packages/core/src/device/profiles/m60.ts @@ -31,7 +31,7 @@ export const m60Profile: DeviceProfile = { packetDelayMs: 1, }, defaults: { density: 2, paperType: "gap" }, - namePrefixes: ["M60", "X2"], + namePrefixes: ["M60"], labelConfig: { supportedPaperTypes: ["gap", "continuous"], defaultPaperType: "gap", @@ -40,3 +40,12 @@ export const m60Profile: DeviceProfile = { defaultSize: { widthMm: 50, heightMm: 30 }, }, }; + +/** X2 shares the M60 transport/protocol but requires different raster encoding. */ +export const x2Profile: DeviceProfile = { + ...m60Profile, + modelId: "x2", + namePrefixes: ["X2"], + rotateRaster90CW: false, + compressionWindowBits: 10, +}; diff --git a/packages/core/src/device/registry.ts b/packages/core/src/device/registry.ts index ae7601e..dc66b01 100644 --- a/packages/core/src/device/registry.ts +++ b/packages/core/src/device/registry.ts @@ -1,7 +1,7 @@ import type { DeviceProfile } from "./types.js"; import { p15Profile } from "./profiles/p15.js"; import { p12Profile } from "./profiles/p12.js"; -import { m60Profile } from "./profiles/m60.js"; +import { m60Profile, x2Profile } from "./profiles/m60.js"; const devices: DeviceProfile[] = []; @@ -32,3 +32,4 @@ export function getRegisteredDevices(): DeviceProfile[] { registerDevice(p15Profile); registerDevice(p12Profile); registerDevice(m60Profile); +registerDevice(x2Profile); diff --git a/packages/core/src/device/types.ts b/packages/core/src/device/types.ts index a3b1dc1..ea50188 100644 --- a/packages/core/src/device/types.ts +++ b/packages/core/src/device/types.ts @@ -23,6 +23,10 @@ export interface DeviceProfile { characteristics: { tx: string; rx: string; cx?: string }; packetSize?: number; flowControl: Partial; + /** Set false when the device expects the editor raster without legacy 90° rotation. */ + rotateRaster90CW?: boolean; + /** Zlib history-window exponent required by the device firmware. */ + compressionWindowBits?: number; defaults: { density: number; paperType: "gap" | "continuous" }; /** Which command to use for print darkness: "density" (1F 70 02) or "thickness" (10 FF 10 00) */ densityCommand?: "density" | "thickness"; diff --git a/packages/core/src/printer.ts b/packages/core/src/printer.ts index f6b1f05..522a423 100644 --- a/packages/core/src/printer.ts +++ b/packages/core/src/printer.ts @@ -138,6 +138,7 @@ export class Printer { density: options.density ?? this.profile.defaults.density, densityCommand: this.profile.densityCommand, paperType: options.paperType ?? this.profile.defaults.paperType, + compressionWindowBits: this.profile.compressionWindowBits, }; const commands = this.protocol.buildPrintSequence(image, mergedOptions); diff --git a/packages/core/src/protocol/types.ts b/packages/core/src/protocol/types.ts index 6f27a3a..b63c860 100644 --- a/packages/core/src/protocol/types.ts +++ b/packages/core/src/protocol/types.ts @@ -29,6 +29,7 @@ export interface PrintSequenceOptions { density?: number; densityCommand?: "density" | "thickness"; paperType?: "gap" | "continuous"; + compressionWindowBits?: number; } export interface ImageBitmap1bpp { diff --git a/packages/core/src/protocol/x2/commands.ts b/packages/core/src/protocol/x2/commands.ts index 2f337ce..7cfb349 100644 --- a/packages/core/src/protocol/x2/commands.ts +++ b/packages/core/src/protocol/x2/commands.ts @@ -1,4 +1,5 @@ import { zlibSync } from "fflate"; +import { deflate } from "pako"; import type { ImageBitmap1bpp, PrintCommand } from "../types.js"; /** 6 zero bytes to wake the printer */ @@ -63,11 +64,22 @@ export function printerLocation(x: number, y: number): PrintCommand { /** * Build a compressed bitmap command: 1F 10 + zlib data - * Compression: standard zlib compress() with default parameters (level 6). + * X2 uses the vendor's 1 KiB zlib window; callers without an override retain + * the existing fflate level-6 behavior for M60 compatibility. */ -export function printBitmap(image: ImageBitmap1bpp): PrintCommand { +export function printBitmap( + image: ImageBitmap1bpp, + compressionWindowBits?: number, +): PrintCommand { const { data: pixels, bytesPerRow, height } = image; - const compressed = zlibSync(pixels, { level: 6 }); + const compressed = compressionWindowBits === undefined + ? zlibSync(pixels, { level: 6 }) + : deflate(pixels, { + level: -1, + windowBits: compressionWindowBits, + memLevel: 8, + strategy: 0, + }); const header = Uint8Array.from([ 0x1f, diff --git a/packages/core/src/protocol/x2/protocol.ts b/packages/core/src/protocol/x2/protocol.ts index 261e212..2424625 100644 --- a/packages/core/src/protocol/x2/protocol.ts +++ b/packages/core/src/protocol/x2/protocol.ts @@ -22,7 +22,7 @@ export class X2Protocol implements PrinterProtocol { image: ImageBitmap1bpp, options: PrintSequenceOptions = {}, ): PrintCommand[] { - const { density, paperType = "gap" } = options; + const { density, paperType = "gap", compressionWindowBits } = options; const commands: PrintCommand[] = []; if (paperType === "gap") { @@ -42,7 +42,7 @@ export class X2Protocol implements PrinterProtocol { commands.push(cmd.feedDots(100)); } - commands.push(cmd.printBitmap(image)); + commands.push(cmd.printBitmap(image, compressionWindowBits)); if (paperType === "gap") { commands.push(cmd.printerLocation(0x20, 0x00)); diff --git a/packages/core/test/protocol/x2-raster.test.ts b/packages/core/test/protocol/x2-raster.test.ts new file mode 100644 index 0000000..29f88fc --- /dev/null +++ b/packages/core/test/protocol/x2-raster.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { findDeviceByName } from "../../src/device/registry"; +import * as cmd from "../../src/protocol/x2/commands"; + +describe("X2 raster encoding", () => { + test("uses the vendor 1 KiB zlib window", () => { + const image = { + data: new Uint8Array(9600), + width: 320, + height: 240, + bytesPerRow: 40, + }; + + const command = cmd.printBitmap(image, 10); + expect(Array.from(command.data.subarray(10, 12))).toEqual([0x28, 0x91]); + }); + + test("isolates raster settings to X2", () => { + const x2 = findDeviceByName("X2-test"); + const m60 = findDeviceByName("M60-test"); + + expect(x2?.rotateRaster90CW).toBe(false); + expect(x2?.compressionWindowBits).toBe(10); + expect(m60?.rotateRaster90CW).toBeUndefined(); + expect(m60?.compressionWindowBits).toBeUndefined(); + }); +}); diff --git a/packages/web/src/editor/editor.tsx b/packages/web/src/editor/editor.tsx index 84492c4..70b798e 100644 --- a/packages/web/src/editor/editor.tsx +++ b/packages/web/src/editor/editor.tsx @@ -10,8 +10,10 @@ import { Palette } from "./palette/palette.tsx"; import { ConnectFlow } from "./connect-flow/connect-flow.tsx"; import { useKeyboardShortcuts, setPrintFn } from "../lib/keyboard.ts"; import { useEditorV2Store } from "../store/editor-store.ts"; +import { usePrinterStore } from "../store/printer-store.ts"; import { getPrinter } from "../hooks/use-web-bluetooth.ts"; -import type { RawImageData } from "@thermoprint/core"; +import { mmToPx } from "../utils/px-mm.ts"; +import { getDevice, type RawImageData } from "@thermoprint/core"; function captureLabel( stage: Konva.Stage, @@ -24,24 +26,31 @@ function captureLabel( const origStageH = stage.height(); const origLayerX = layer.x(); const origLayerY = layer.y(); - const displayScale = layer.scaleX(); + const origScaleX = layer.scaleX(); + const origScaleY = layer.scaleY(); - const displayW = widthPx * displayScale; - const displayH = heightPx * displayScale; - - // Temporarily resize so toCanvas captures only the label - stage.width(displayW); - stage.height(displayH); + stage.width(widthPx); + stage.height(heightPx); layer.x(0); layer.y(0); + layer.scaleX(1); + layer.scaleY(1); - const canvas = stage.toCanvas({ pixelRatio: 1 / displayScale }); + const canvas = stage.toCanvas({ + x: 0, + y: 0, + width: widthPx, + height: heightPx, + pixelRatio: 1, + }); // Restore stage.width(origStageW); stage.height(origStageH); layer.x(origLayerX); layer.y(origLayerY); + layer.scaleX(origScaleX); + layer.scaleY(origScaleY); stage.batchDraw(); return canvas; @@ -89,17 +98,32 @@ export function Editor() { // Wait a frame for Konva to re-render without selection handles await new Promise((r) => requestAnimationFrame(r)); - // Capture the label region at 1:1 pixel resolution - const raw = captureLabel(stage, label.widthPx, label.heightPx); - const canvas = rotateCanvas90CW(raw); - const rotatedW = canvas.width; - const rotatedH = canvas.height; + const widthDots = mmToPx(label.widthMm); + const heightDots = mmToPx(label.heightMm); + const raw = captureLabel(stage, widthDots, heightDots); + if (raw.width !== widthDots || raw.height !== heightDots) { + throw new Error( + `Raster size mismatch: expected ${widthDots}×${heightDots}, got ${raw.width}×${raw.height}`, + ); + } + + const modelId = usePrinterStore.getState().modelId; + const profile = modelId ? getDevice(modelId) : null; + const canvas = profile?.rotateRaster90CW === false + ? raw + : rotateCanvas90CW(raw); + const outputWidth = canvas.width; + const outputHeight = canvas.height; // Send at the label's natural pixel size — no padding to print head width. // The printer handles positioning; padding would 4x the data for narrow labels. const ctx = canvas.getContext("2d")!; - const imgData = ctx.getImageData(0, 0, rotatedW, rotatedH); - const imageData: RawImageData = { data: imgData.data, width: rotatedW, height: rotatedH }; + const imgData = ctx.getImageData(0, 0, outputWidth, outputHeight); + const imageData: RawImageData = { + data: imgData.data, + width: outputWidth, + height: outputHeight, + }; // Listen for real progress events from the printer const offProgress = (p: { bytesSent: number; totalBytes: number }) => {