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)
+ }))
})