Skip to content
Merged
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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
})

Expand Down
67 changes: 66 additions & 1 deletion demo/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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' },
Expand All @@ -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
Expand All @@ -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 () => {
Expand Down
12 changes: 12 additions & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ <h2 class="text-sm font-semibold mb-3">Connect</h2>
<span id="statusPill" class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold"><span id="status">idle</span></span>
<button id="disconnectBtn" disabled class="rounded-lg border border-gray-200 dark:border-neutral-800 px-4 py-2 text-sm font-medium cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed">Disconnect</button>
<button id="testPrintBtn" disabled class="rounded-lg border border-gray-200 dark:border-neutral-800 px-4 py-2 text-sm font-medium cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed">Test Print</button>
<button id="diagnosticPatternBtn" disabled title="Prints a known vertical-stripe test pattern (16/8/4/2/1px bands, 256px wide) — for debugging garbled/banded image output"
class="rounded-lg border border-gray-200 dark:border-neutral-800 px-4 py-2 text-sm font-medium cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed">Print diagnostic pattern</button>
<button id="diagnosticPatternTinyBtn" disabled title="Same test pattern at 1/8th the size (64px wide, 8/4/2/1px bands) — tests whether corruption depends on payload size"
class="rounded-lg border border-gray-200 dark:border-neutral-800 px-4 py-2 text-sm font-medium cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed">Print diagnostic pattern (tiny)</button>
</div>
</div>

Expand Down Expand Up @@ -177,6 +181,14 @@ <h2 class="text-sm font-semibold mb-3">Receipt</h2>
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" />
</div>
<div class="flex-1">
<label for="imageModeSelect" class="block mb-1 text-sm font-semibold">Image mode</label>
<select id="imageModeSelect" title="Raster image wire format — try 'raster' if images print corrupted/banded on a clone printer"
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">
<option value="" selected>Default (column)</option>
<option value="raster">raster</option>
</select>
</div>
</div>

<label for="textInput" class="block mt-3 mb-1 text-sm font-semibold">Text</label>
Expand Down
41 changes: 41 additions & 0 deletions docs/notes/13-mtp-ii-bluetooth-image-corruption.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions src/Printer/ReceiptBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down
28 changes: 28 additions & 0 deletions test/Images/image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <m=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 () => {
Expand Down Expand Up @@ -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)
}))
})
Loading