From 43e77269250a84370b0666637ba9d820d6f12133 Mon Sep 17 00:00:00 2001 From: GomdimApps Date: Tue, 22 Sep 2026 13:45:43 -0300 Subject: [PATCH] feat: add imageMode configuration option for raster and column image wire formats --- AGENTS.md | 5 ++ README.md | 7 ++ demo/app.js | 67 ++++++++++++++++++- demo/index.html | 12 ++++ .../13-mtp-ii-bluetooth-image-corruption.md | 41 ++++++++++++ src/Printer/ReceiptBuilder.ts | 2 + src/types.ts | 11 +++ test/Images/image.test.ts | 28 ++++++++ 8 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 docs/notes/13-mtp-ii-bluetooth-image-corruption.md diff --git a/AGENTS.md b/AGENTS.md index 40414d9..f67d5bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,11 @@ time — one line each, full report in `docs/notes/`. 12. **A vendor's own "virtual COM port" driver (e.g. Epson's TM Virtual Port tool) isn't listed by Web Serial's picker** — not a filter bug. → [docs/notes/12](docs/notes/12-vendor-virtual-com-drivers-not-listed.md) +13. **Image printing over Bluetooth is unreliable on the MTP-II/MP58C7 + clone family** — corrupted/banded output regardless of `imageMode`, + BLE chunk size or pacing; use `transport: 'serial'`/`'usb'` for + images on this hardware instead. + → [docs/notes/13](docs/notes/13-mtp-ii-bluetooth-image-corruption.md) ## Coding conventions diff --git a/README.md b/README.md index e17f538..9545039 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,12 @@ Find `service`/`characteristic` with a BLE scanner app (nRF Connect, LightBlue) against the printer — most clones use a vendor-specific service. +Printing images over Bluetooth on an MTP-II/MP58C7-family clone (also +sold as HPRT HM-A200U, PixPos MP58C7, ...)? Confirmed unreliable +regardless of `imageMode`/chunk size/pacing — use `transport: 'serial'` +or `'usb'` for image content on that hardware instead. See +[docs/notes/13](docs/notes/13-mtp-ii-bluetooth-image-corruption.md). + ### Web Serial (USB cable, recommended default) Reliable across Windows/Linux/macOS, no extra software. Chrome/Edge @@ -226,6 +232,7 @@ const printer = new WebEscposPrinter({ language: 'star-prnt', // 'esc-pos' | 'star-prnt' | 'star-line', default 'esc-pos' codepageMapping: 'xprinter', // for non-standard clone printers printerModel: 'epson-tm-t88vi',// lets ReceiptPrinterEncoder auto-configure known-model defaults + imageMode: 'raster', // 'column' (default) | 'raster' — force GS v 0 for clone printers whose firmware mishandles the legacy ESC * band format feedBeforeCut: 4, // blank lines fed before the cut, default 4 }) diff --git a/demo/app.js b/demo/app.js index 1e91a34..7ab0c84 100644 --- a/demo/app.js +++ b/demo/app.js @@ -31,7 +31,10 @@ const qzPrinterSelect = document.getElementById('qzPrinterSelect') const qzConnectBtn = document.getElementById('qzConnectBtn') const printBtn = document.getElementById('printBtn') const testPrintBtn = document.getElementById('testPrintBtn') +const diagnosticPatternBtn = document.getElementById('diagnosticPatternBtn') +const diagnosticPatternTinyBtn = document.getElementById('diagnosticPatternTinyBtn') const paperWidthSelect = document.getElementById('paperWidthSelect') +const imageModeSelect = document.getElementById('imageModeSelect') const textInput = document.getElementById('textInput') const alignSelect = document.getElementById('alignSelect') const feedBeforeCutInput = document.getElementById('feedBeforeCutInput') @@ -118,6 +121,8 @@ printer.onStatusChange((event) => { disconnectBtn.disabled = !connected printBtn.disabled = !connected testPrintBtn.disabled = !connected + diagnosticPatternBtn.disabled = !connected + diagnosticPatternTinyBtn.disabled = !connected connectBtn.disabled = connected || !supported connectCompatBtn.disabled = connected || !supported connectManualProfileBtn.disabled = connected || !supported @@ -249,6 +254,10 @@ function selectedPaperWidth() { return paperWidthSelect.value || undefined } +function selectedImageMode() { + return imageModeSelect.value || undefined +} + function selectedFeedBeforeCut() { const value = feedBeforeCutInput.value.trim() return value === '' ? undefined : Number(value) @@ -258,6 +267,7 @@ testPrintBtn.onclick = async () => { try { await printer.printReceipt({ paperWidth: selectedPaperWidth(), + imageMode: selectedImageMode(), content: [ { type: 'text', value: 'TEST PRINT', align: 'center', bold: true }, { type: 'rule' }, @@ -275,6 +285,59 @@ testPrintBtn.onclick = async () => { } } +/** + * Vertical black/white stripe test pattern, stacked in bands of decreasing + * stripe width top to bottom. Used to visually pin down *how* a printer + * garbles image output: wherever adjacent stripes fuse or shift on paper + * marks the exact byte/column period the corruption happens at, which a + * real photo can't tell you as precisely. + */ +function buildStripePatternDataUrl(width, bandHeight, stripeWidths) { + const height = bandHeight * stripeWidths.length + + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + ctx.fillStyle = '#fff' + ctx.fillRect(0, 0, width, height) + ctx.fillStyle = '#000' + stripeWidths.forEach((stripeWidth, bandIndex) => { + const y = bandIndex * bandHeight + for (let x = 0; x < width; x += stripeWidth * 2) { + ctx.fillRect(x, y, stripeWidth, bandHeight) + } + }) + return canvas.toDataURL('image/png') +} + +async function printDiagnosticPattern(label, dataUrl) { + try { + await printer.printReceipt({ + paperWidth: selectedPaperWidth(), + imageMode: selectedImageMode(), + content: [ + { type: 'text', value: 'DIAGNOSTIC PATTERN', align: 'center', bold: true }, + { type: 'text', value: label, align: 'center' }, + // minWidth: 1 — bypasses the usual 224px imageMinWidth floor so the + // "tiny" pattern actually prints small instead of being upscaled. + { type: 'image', source: dataUrl, align: 'center', minWidth: 1 }, + { type: 'newline', lines: 2 }, + ], + cut: 'full', + }) + log('diagnostic pattern sent') + } catch (error) { + log(`diagnostic pattern failed: ${error.message}`) + } +} + +diagnosticPatternBtn.onclick = () => + printDiagnosticPattern('256px wide, bands top to bottom: 16/8/4/2/1px stripes', buildStripePatternDataUrl(256, 16, [16, 8, 4, 2, 1])) + +diagnosticPatternTinyBtn.onclick = () => + printDiagnosticPattern('64px wide, bands top to bottom: 8/4/2/1px stripes', buildStripePatternDataUrl(64, 8, [8, 4, 2, 1])) + function buildJobFromForm() { const lines = textInput.value.split('\n').map((l) => l.trim()).filter(Boolean) const align = alignSelect.value @@ -293,7 +356,9 @@ function buildJobFromForm() { if (ruleCheck.checked || ruleSafeModeCheck.checked) content.push({ type: 'rule', safeMode: ruleSafeModeCheck.checked }) content.push({ type: 'newline', lines: 2 }) - return content.length > 0 ? { paperWidth: selectedPaperWidth(), feedBeforeCut: selectedFeedBeforeCut(), content, cut: 'full' } : null + return content.length > 0 + ? { paperWidth: selectedPaperWidth(), imageMode: selectedImageMode(), feedBeforeCut: selectedFeedBeforeCut(), content, cut: 'full' } + : null } printBtn.onclick = async () => { diff --git a/demo/index.html b/demo/index.html index e9bfe6e..b40cc68 100644 --- a/demo/index.html +++ b/demo/index.html @@ -145,6 +145,10 @@

Connect

idle + + @@ -177,6 +181,14 @@

Receipt

title="Blank lines fed before the physical cut, so the cutter doesn't slice through the last printed content" class="w-full bg-gray-50 dark:bg-neutral-950 border border-gray-200 dark:border-neutral-800 rounded-lg px-2.5 py-2 text-sm" /> +
+ + +
diff --git a/docs/notes/13-mtp-ii-bluetooth-image-corruption.md b/docs/notes/13-mtp-ii-bluetooth-image-corruption.md new file mode 100644 index 0000000..e022cde --- /dev/null +++ b/docs/notes/13-mtp-ii-bluetooth-image-corruption.md @@ -0,0 +1,41 @@ +# Gotcha #13: image printing over Bluetooth is unreliable on the MTP-II/MP58C7 family — use Serial + +Confirmed on a real MP58C7 (sold under several rebrands of the same +Xiamen Hanin Electronic Technology hardware — MTP-II, HPRT HM-A200U, +PixPos MP58C7; `bluetooth/profiles.ts`'s existing `MTP-II` profile +already targets this device family): printing an image over Bluetooth +comes out as banded/sheared garbage, while text, barcode and QR print +perfectly over the same connection. Exhaustively ruled out before +landing on this: + +- `imageMode: 'column'` vs `'raster'` — identical corruption in both, + so it isn't the ESC/POS command dialect. +- BLE write chunk size (`messageSize`: 100 default, 20, 8) and + inter-write delay (`sleepAfterCommand`: 0, 10, 20ms), individually and + combined — none fixed it; some combinations made it visibly worse. +- Payload size — even a ~256-byte synthetic stripe-pattern image + corrupts the same way as a full-size photo. +- The printer's own graphics engine isn't at fault: the *exact same* + encoded bytes (`column` or `raster`, doesn't matter) print perfectly + when sent over Serial/USB instead of Bluetooth. + +**Confirmed fix: use `transport: 'serial'` (or `'usb'`) instead of +Bluetooth for any print job containing an image on this printer family.** +This isn't a library-side bug to patch: Serial/USB send the encoded +bytes as one continuous write with no artificial chunking +(`SerialTransport.ts`/`UsbTransport.ts`), while Bluetooth GATT writes +are always chunked through `writeChunked.ts`. The corruption most +likely originates in this printer's separate BLE-to-serial bridge chip +— common on cheap clones — whose buffer/flow-control couldn't be tuned +into working from the browser side across every chunk-size/pacing +combination tried. + +No code-level fix — same shape as gotcha #11 ("no code-level fix, +prefer Serial"). Note that `bluetooth/profiles.ts`'s existing `MTP-II` +profile's `messageSize: 20, sleepAfterCommand: 20` was added for a +*disconnect* mid-print, a different failure mode on the same hardware +family — it doesn't fix this image-corruption issue, so don't assume +that profile makes Bluetooth image printing safe on this device. + +--- +[AGENTS.md](../../AGENTS.md) gotcha #13. diff --git a/src/Printer/ReceiptBuilder.ts b/src/Printer/ReceiptBuilder.ts index 2456233..c070c31 100644 --- a/src/Printer/ReceiptBuilder.ts +++ b/src/Printer/ReceiptBuilder.ts @@ -32,6 +32,7 @@ export async function buildReceiptBytes(job: PrintJob, defaults: WebEscposPrinte // these keys are present at all, even with value `undefined`. const codepageMapping = job.codepageMapping ?? defaults.codepageMapping const printerModel = job.printerModel ?? defaults.printerModel + const imageMode = job.imageMode ?? defaults.imageMode const encoder = new ReceiptPrinterEncoder({ columns, @@ -41,6 +42,7 @@ export async function buildReceiptBytes(job: PrintJob, defaults: WebEscposPrinte feedBeforeCut: job.feedBeforeCut ?? defaults.feedBeforeCut, ...(codepageMapping !== undefined ? { codepageMapping } : {}), ...(printerModel !== undefined ? { printerModel } : {}), + ...(imageMode !== undefined ? { imageMode } : {}), }) encoder.initialize() diff --git a/src/types.ts b/src/types.ts index 240faec..a1b9883 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,15 @@ export interface WebEscposPrinterConfig { codepageMapping?: unknown /** Known printer model (e.g. 'epson-tm-t88vi') so ReceiptPrinterEncoder can auto-configure sensible defaults for it. */ printerModel?: string + /** + * Raster image wire format: 'column' (default) sends the legacy ESC * + * 24-dot band sequence; 'raster' sends a single GS v 0 command. The + * encoder only picks 'raster' on its own for a recognized `printerModel` + * — set this explicitly for unrecognized/clone printers whose firmware + * mishandles the band format's line-spacing dance (confirmed cause of + * banded/ghosted image output on at least one 58mm clone). + */ + imageMode?: 'column' | 'raster' /** Default threshold (0-255) for image dithering. */ imageThreshold: number /** Maximum width, in pixels, to resize images to before printing. */ @@ -183,6 +192,8 @@ export interface PrintJob { language?: PrinterLanguage codepageMapping?: unknown printerModel?: string + /** Per-job override of WebEscposPrinterConfig.imageMode. */ + imageMode?: 'column' | 'raster' /** Paper cut at the end. `false` to skip cutting. Default: 'full'. */ cut?: 'full' | 'partial' | false /** Blank lines fed before the cut. See WebEscposPrinterConfig.feedBeforeCut. */ diff --git a/test/Images/image.test.ts b/test/Images/image.test.ts index 6377ace..b543cb0 100644 --- a/test/Images/image.test.ts +++ b/test/Images/image.test.ts @@ -5,6 +5,13 @@ import { pixelFixture } from '../helpers/fixtures' import { buildBytes } from '../helpers/receipt' import { asciiBytes, containsBytes } from '../helpers/assertBytes' +// ESC 3 36 (custom line spacing) + ESC * 33 (24-dot band) — the legacy +// 'column' image format's command headers. +const COLUMN_LINE_SPACING_HEADER = [27, 51, 36] +const COLUMN_BAND_HEADER = [27, 42, 33] +// GS v 0 — the single-shot 'raster' image format's command header. +const RASTER_HEADER = [29, 118, 48, 0] + describe('loadImageFromSource', () => { it('loads a base64 dataURL source into a real HTMLImageElement', () => withDom(async () => { @@ -103,4 +110,25 @@ describe('image element, end-to-end through buildReceiptBytes()', () => { expect(warned, 'expected applyImageElement to console.warn on a degenerate image').toBe(true) expect(containsBytes(bytes, asciiBytes('still here'))).toBe(true) })) + + // Regression test for the MP58C7 (58mm clone) bug: the encoder defaults + // to the legacy 'column' image format, which some clone firmware + // reassembles wrong (banded/ghosted output) — imageMode: 'raster' is the + // documented escape hatch. See AGENTS.md gotcha list / docs/notes. + it('defaults to the legacy column image format (no imageMode set)', () => + withDom(async () => { + const fixture = pixelFixture(32, 32) + const bytes = await buildBytes([{ type: 'image', source: fixture.dataUrl }]) + expect(containsBytes(bytes, COLUMN_LINE_SPACING_HEADER)).toBe(true) + expect(containsBytes(bytes, COLUMN_BAND_HEADER)).toBe(true) + expect(containsBytes(bytes, RASTER_HEADER)).toBe(false) + })) + + it("imageMode: 'raster' sends a single GS v 0 command instead of the column band sequence", () => + withDom(async () => { + const fixture = pixelFixture(32, 32) + const bytes = await buildBytes([{ type: 'image', source: fixture.dataUrl }], { imageMode: 'raster' }) + expect(containsBytes(bytes, RASTER_HEADER)).toBe(true) + expect(containsBytes(bytes, COLUMN_LINE_SPACING_HEADER)).toBe(false) + })) })