Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
11 changes: 10 additions & 1 deletion packages/core/src/device/profiles/m60.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
};
3 changes: 2 additions & 1 deletion packages/core/src/device/registry.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];

Expand Down Expand Up @@ -32,3 +32,4 @@ export function getRegisteredDevices(): DeviceProfile[] {
registerDevice(p15Profile);
registerDevice(p12Profile);
registerDevice(m60Profile);
registerDevice(x2Profile);
4 changes: 4 additions & 0 deletions packages/core/src/device/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export interface DeviceProfile {
characteristics: { tx: string; rx: string; cx?: string };
packetSize?: number;
flowControl: Partial<FlowControlOptions>;
/** 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";
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface PrintSequenceOptions {
density?: number;
densityCommand?: "density" | "thickness";
paperType?: "gap" | "continuous";
compressionWindowBits?: number;
}

export interface ImageBitmap1bpp {
Expand Down
18 changes: 15 additions & 3 deletions packages/core/src/protocol/x2/commands.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down Expand Up @@ -63,11 +64,22 @@ export function printerLocation(x: number, y: number): PrintCommand {

/**
* Build a compressed bitmap command: 1F 10 <wh> <wl> <hh> <hl> <len4> + 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,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/protocol/x2/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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));
Expand Down
27 changes: 27 additions & 0 deletions packages/core/test/protocol/x2-raster.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
56 changes: 40 additions & 16 deletions packages/web/src/editor/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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 }) => {
Expand Down