Last active
March 25, 2025 18:10
-
-
Save come25136/44c0f6efbfc25839c808ffcea5c2347d to your computer and use it in GitHub Desktop.
サーマルプリンタで遊ぶ用のクラス
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 画像をグレイスケール化 | |
function toGrayscale(array: Uint8ClampedArray, width: number, height: number) { | |
// https://qiita.com/ltzz/items/2160b5a73c206e14bde3 | |
let outputArray = new Uint8Array(width * height); | |
for (let y = 0; y < height; y += 4) { | |
for (let x = 0; x < width; x += 4) { | |
for (let dy = 0; dy < 4; ++dy) { | |
for (let dx = 0; dx < 4; ++dx) { | |
const r = array[((y + dy) * width + (x + dx)) * 4 + 0]; | |
const g = array[((y + dy) * width + (x + dx)) * 4 + 1]; | |
const b = array[((y + dy) * width + (x + dx)) * 4 + 2]; | |
const gray = (r + g + b) / 3 | 0; | |
outputArray[(y + dy) * width + (x + dx)] = gray; | |
} | |
} | |
} | |
} | |
return outputArray; | |
} | |
// 画像を誤差拡散で2値化 | |
function errorDiffusion1CH(u8array: Uint8Array, width: number, height: number) { | |
// https://qiita.com/ltzz/items/2160b5a73c206e14bde3 | |
let errorDiffusionBuffer = new Int16Array(width * height); // 誤差拡散法で元画像+処理誤差を一旦保持するバッファ Uint8だとオーバーフローする | |
let outputData = new Uint8Array(width * height); | |
for (let i = 0; i < width * height; ++i) errorDiffusionBuffer[i] = u8array[i]; | |
for (let y = 0; y < height; y += 1) { | |
for (let x = 0; x < width; x += 1) { | |
let outputValue; | |
let errorValue; | |
const currentPositionValue = errorDiffusionBuffer[y * width + x]; | |
if (currentPositionValue >= 128) { | |
outputValue = 255; | |
errorValue = currentPositionValue - 255; | |
} else { | |
outputValue = 0; | |
errorValue = currentPositionValue; | |
} | |
if (x < width - 1) { | |
errorDiffusionBuffer[y * width + x + 1] += 5 * errorValue / 16 | 0; | |
} | |
if (0 < x && y < height - 1) { | |
errorDiffusionBuffer[(y + 1) * width + x - 1] += 3 * errorValue / 16 | 0; | |
} | |
if (y < height - 1) { | |
errorDiffusionBuffer[(y + 1) * width + x] += 5 * errorValue / 16 | 0; | |
} | |
if (x < width - 1 && y < height - 1) { | |
errorDiffusionBuffer[(y + 1) * width + x + 1] += 3 * errorValue / 16 | 0; | |
} | |
outputData[y * width + x] = outputValue; | |
} | |
} | |
return outputData; | |
} | |
const ESC = 0x1B; | |
const GS = 0x1D; | |
const US = 0x1F; | |
// canvas画像をグレイスケール→誤差拡散で2値化 | |
function getErrorDiffusionImage(cvs: HTMLCanvasElement) { | |
// https://qiita.com/ltzz/items/2160b5a73c206e14bde3 | |
const ctx = cvs.getContext('2d'); | |
if (ctx === null) throw new Error("ctx is null"); | |
const inputData = ctx.getImageData(0, 0, cvs.width, cvs.height).data; | |
const output = ctx.createImageData(cvs.width, cvs.height); | |
let outputData = output.data; | |
const grayArray = toGrayscale(inputData, cvs.width, cvs.height); | |
const funcOutput = errorDiffusion1CH(grayArray, cvs.width, cvs.height) | |
for (let y = 0; y < cvs.height; y += 1) { | |
for (let x = 0; x < cvs.width; x += 1) { | |
const value = funcOutput[y * cvs.width + x]; | |
outputData[(y * cvs.width + x) * 4 + 0] = value; | |
outputData[(y * cvs.width + x) * 4 + 1] = value; | |
outputData[(y * cvs.width + x) * 4 + 2] = value; | |
outputData[(y * cvs.width + x) * 4 + 3] = 0xff; | |
} | |
} | |
return outputData; | |
} | |
// canvasの画像データからラスターイメージデータ取得 | |
function getPrintImage(cvs: HTMLCanvasElement, start_y: number) { | |
const inputData = getErrorDiffusionImage(cvs); | |
if (start_y > cvs.height) return null; | |
let height = (start_y + 255 < cvs.height) ? start_y + 255 : cvs.height; | |
let outputArray = new Uint8Array(cvs.width * (height - start_y) / 8); | |
let bytes = 0; | |
for (let y = start_y; y < height; y++) { | |
for (let x = 0; x < cvs.width; x += 8) { | |
let bit8 = 0; | |
for (let i = 0; i < 8; i++) { | |
let r = inputData[((x + i) + y * cvs.width) * 4]; | |
bit8 |= (r & 0x01) << (7 - i); | |
} | |
outputArray[bytes] = ~bit8; | |
bytes++; | |
} | |
} | |
return outputArray; | |
} | |
// 印刷処理 | |
export async function print(cvs: HTMLCanvasElement) { | |
let port: SerialPort | null = null; | |
let writer = null; | |
let reader = null; | |
try { | |
port = await navigator.serial.requestPort(); | |
await port.open({ baudRate: 115200 }); | |
writer = port.writable.getWriter(); | |
await writer.write(new Uint8Array([ESC, 0x40, 0x02])); // reset | |
await writer.write(new Uint8Array([ESC, 0x40]).buffer); // initialize | |
await writer.write(new Uint8Array([ESC, 0x61, 0x01]).buffer); // align center | |
await writer.write(new Uint8Array([US, 0x11, 0x37, 0x96]).buffer); // concentration coefficiennt | |
await writer.write(new Uint8Array([US, 0x11, 0x02, 0x01]).buffer); // concentration | |
// 画像出力 | |
let start_y = 0; | |
while (true) { | |
let bit_image = getPrintImage(cvs, start_y); // 255ラインのラスターデータを取得 | |
if (!bit_image) break; | |
let width = cvs.width / 8; | |
await writer.write(new Uint8Array([GS, 0x76, 0x30, 0x00])); // image | |
await writer.write(new Uint8Array([width & 0x00FF, (width >> 8) & 0x00FF])); // width | |
let height = bit_image.length / width; | |
await writer.write(new Uint8Array([height & 0x00FF, (height >> 8) & 0x00FF])); // height | |
await writer.write(bit_image); // raster bit image | |
start_y += (height + 1); | |
} | |
await writer.write(new Uint8Array([ESC, 0x64, 0x03]).buffer); // 行送り(feed line) | |
// 印字完了まで待つ | |
// await writer.write(new Uint8Array([US, 0x11, 0x0E]).buffer); // 電源が切れるまでの秒数を要求 | |
// await writer.write(new Uint8Array([US, 0x11, 0x08]).buffer); // 電池残量を要求 | |
reader = port.readable.getReader(); | |
while (true) { | |
const { value, done } = await reader.read(); | |
if (done) { | |
console.log("reader done"); | |
break; | |
} | |
console.log("device timer:" + value[2]); | |
if (value[2] == 0) break; | |
} | |
reader.releaseLock(); | |
reader = null; | |
await writer.write(new Uint8Array([ESC, 0x40, 0x02])); // reset | |
// writer.releaseLock(); | |
// writer = null; | |
// await port.close(); | |
// port = null; | |
alert("印刷が完了しました!") | |
} catch (error) { | |
alert("Error:" + error); | |
if (writer) { | |
writer.releaseLock(); | |
} | |
if (reader) { | |
reader.releaseLock(); | |
} | |
if (port) { | |
await port.close(); | |
} | |
} | |
} | |
export class M02S { | |
protected connected = false; | |
protected serialPort: SerialPort; | |
protected reader: ReadableStreamDefaultReader<Uint8Array> | null = null; | |
protected writer: WritableStreamDefaultWriter | null = null; | |
/** | |
* ブラウザにシリアルポートのリクエストを行い、利用可能なポートリストを表示してもらう | |
*/ | |
static async requestSerialPort() { | |
const serialPort = await navigator.serial.requestPort(); | |
return new M02S(serialPort); | |
} | |
private static commands = { | |
/** | |
* Initialize | |
*/ | |
initialize: () => new Uint8Array([ESC, 0x40]), | |
/** | |
* Alignment (not functioning, always centered) | |
*/ | |
alignment: (alignment: 'left' | 'center' | 'right') => { | |
const map = { | |
left: 0x00, | |
center: 0x01, | |
right: 0x02, | |
} as const | |
return new Uint8Array([ESC, 0x61, map[alignment]]) | |
}, | |
/** | |
* Concentration coefficient (0x96 = M02S dedicated) | |
*/ | |
concentrationCoefficient: (coefficient: number = 0x96) => new Uint8Array([US, 0x11, 0x37, coefficient]), | |
/** | |
* Concentration | |
*/ | |
concentration: (concentration: 'weak' | 'normal' | 'thick') => { | |
const map = { | |
weak: 0x01, | |
normal: 0x03, | |
thick: 0x04, | |
} as const | |
return new Uint8Array([US, 0x11, 0x02, map[concentration]]) | |
}, | |
/** | |
* Raster bit image | |
*/ | |
rasterBitImage: (mode: 'normal' | 'doubleWidth' | 'doubleHeight' | 'quadruple', width: number, height: number, data: Uint8Array) => { | |
const modeMap = { | |
normal: 0x00, | |
doubleWidth: 0x01, | |
doubleHeight: 0x02, | |
quadruple: 0x03, | |
} as const | |
return new Uint8Array([ | |
GS, 0x76, 0x30, // command | |
modeMap[mode], // mode | |
width & 0x00FF, (width >> 8) & 0x00FF, // width | |
height & 0x00FF, (height >> 8) & 0x00FF, // height | |
...data | |
]) | |
}, | |
/** | |
* Feed lines | |
*/ | |
feedLines: (lines: number) => new Uint8Array([ESC, 0x64, lines]), | |
/** | |
* Number of seconds until the power is turned off | |
*/ | |
powerIsTurnedOffSeconds: () => new Uint8Array([US, 0x11, 0x0E]), | |
/** | |
* Battery capacity | |
*/ | |
getBatteryCapacity: () => new Uint8Array([US, 0x11, 0x08]), | |
/** | |
* Status | |
*/ | |
status: () => new Uint8Array([US, 0x11, 0x11]), | |
/** | |
* Reset | |
*/ | |
reset: () => new Uint8Array([ESC, 0x40, 0x02]), | |
/** | |
* Feed paper cut | |
*/ | |
feedPaperCut: () => new Uint8Array([GS, 0x56, 0x01]), | |
/** | |
* Get firmware version | |
*/ | |
getFirmwareVersion: () => new Uint8Array([US, 0x11, 0x07]), | |
} | |
constructor(port: SerialPort) { | |
this.serialPort = port; | |
} | |
async connect() { | |
await this.disconnect() | |
await this.serialPort.open({ baudRate: 115200 }); | |
this.reader = this.serialPort.readable.getReader(); | |
this.writer = this.serialPort.writable.getWriter(); | |
this.connected = true; | |
} | |
async disconnect() { | |
if (this.reader) { | |
console.log('reader disconnect') | |
await this.reader.cancel() | |
this.reader.releaseLock() | |
this.reader = null | |
console.log('reader disconnected') | |
} | |
if (this.writer) { | |
console.log('writer disconnect') | |
this.writer.releaseLock() | |
this.writer = null | |
console.log('writer disconnected') | |
} | |
if (this.connected) { | |
await this.serialPort.close() | |
this.connected = false | |
} | |
} | |
async dispose() { | |
await this.disconnect() | |
await this.serialPort.forget() | |
} | |
async sendSerial(data: Uint8Array) { | |
if (this.writer === null) throw new Error("writer is null") | |
await this.writer.write(data.buffer) | |
} | |
recvSerial(): Promise<Uint8Array> { | |
return new Promise<Uint8Array>(async (resolve, reject) => { | |
if (this.reader === null) throw new Error("reader is null") | |
const timeout = setTimeout(() => reject(new Error("timeout")), 5000) | |
const { value, done } = await this.reader.read(); | |
clearTimeout(timeout) | |
if (done) { | |
return reject(new Error("reader done")) | |
} | |
return resolve(value) | |
}) | |
} | |
async print(cvs: HTMLCanvasElement) { | |
try { | |
if (this.reader === null) throw new Error("reader is null") | |
if (this.writer === null) throw new Error("writer is null") | |
// await this.serialWrite(M02S.commands.reset()) | |
// const initializeResult = await this.waitRecv() | |
// if ((initializeResult[0] === 0x1A && | |
// initializeResult[1] === 0x0F && | |
// initializeResult[2] === 0x0C | |
// ) === false) throw new Error("initialize failed") | |
// console.log("initialize success") | |
await this.sendSerial(M02S.commands.alignment('center')) | |
await this.sendSerial(M02S.commands.concentrationCoefficient()) | |
await this.sendSerial(M02S.commands.concentration('weak')) | |
// 画像出力 | |
let start_y = 0; | |
while (true) { | |
const imageBits = getPrintImage(cvs, start_y); // 255ラインのラスターデータを取得 | |
if (imageBits === null) break; | |
const imageWidth = cvs.width / 8; | |
const imageHeight = imageBits.length / imageWidth; | |
await this.sendSerial(M02S.commands.rasterBitImage('normal', imageWidth, imageHeight, imageBits)) | |
start_y += (imageHeight + 1); | |
} | |
await this.sendSerial(M02S.commands.feedLines(3)) | |
// 印字完了まで待つ | |
await this.sendSerial(M02S.commands.getBatteryCapacity()); // 電源が切れるまでの秒数を要求(何か返ってくる命令投げとけばいい) | |
// await writer.write(new Uint8Array([US, 0x11, 0x08]).buffer); // 電池残量を要求 | |
await this.recvSerial() | |
// writer.releaseLock(); | |
// writer = null; | |
// await port.close(); | |
// port = null; | |
alert("印刷が完了しました!") | |
} catch (error) { | |
alert("Error:" + error); | |
await this.disconnect() | |
} | |
} | |
async getFirmwareVersion() { | |
await this.sendSerial(M02S.commands.getFirmwareVersion()) | |
const result = await this.recvSerial() | |
return `${result[2]}.${result[3]}.${result[4]}` | |
} | |
/** | |
* | |
* @returns 電池残量(0-100) | |
*/ | |
async getBatteryCapacity() { | |
await this.sendSerial(M02S.commands.getBatteryCapacity()) | |
const result = await this.recvSerial() | |
return result[2] | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment