diff --git a/AGENTS.md b/AGENTS.md
index 3e06ee9..40414d9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,23 +6,24 @@ before touching code.
## What this is
`web-escpos-printer` wraps ESC/POS receipt printing, **entirely in the
-browser, no Node at runtime**. Two transports share one `PrinterTransport`
-interface: Web Bluetooth (direct) and QZ Tray (talks to the
+browser, no Node at runtime**. Four transports share one `PrinterTransport`
+interface: Web Bluetooth (direct), QZ Tray (talks to the
[QZ Tray](https://qz.io) desktop app's local websocket, which talks to an
-OS-registered printer — USB or otherwise). Ships in two forms from one
-source via `webpack.config.js` (array of two configs):
-
-- **Standalone UMD** (`build/web-escpos-printer.js`) — fully self-contained,
- bundles `@point-of-sale/*`, `canvas-dither`, `qrcode-generator`, `qz-tray`
- inside. Drop into a `
```
-Pin a version for production (`@x.y.z` after the package name, e.g.
-`web-escpos-printer@1.1.0`) instead of the unpinned URL above — it always
-resolves to the latest release, which can break you without warning.
-
-See [demo/index.html](demo/index.html) for a full working example, and
+Pin a version (`@x.y.z`) for production — an unpinned URL always resolves
+to latest, which can break you without warning. See
+[demo/index.html](demo/index.html) for a full example, and
`docker compose up` (see [docker-compose.yml](docker-compose.yml)) to run
-it locally at `http://localhost:3000/`.
+it at `http://localhost:3000/`.
## npm package usage
@@ -81,25 +71,33 @@ async function onPrintClick() {
}
```
-`require('web-escpos-printer')` (Vue 2, older webpack, plain Node
-tooling) resolves to the same self-contained bundle as standalone above;
-`import` gets the ESM build with `@point-of-sale/receipt-printer-encoder`
-and `qz-tray` as externals instead. Full TypeScript declarations ship in
-`build/types`.
+`require('web-escpos-printer')` resolves to the same self-contained UMD
+bundle as standalone above; `import` gets the ESM build with
+`@point-of-sale/receipt-printer-encoder`/`qz-tray` as externals instead.
+TypeScript declarations ship in `build/types`.
## API
```ts
class WebEscposPrinter {
- static isSupported(): boolean // Web Bluetooth support
- static isQzSupported(): boolean // WebSocket support (not whether QZ Tray itself is running)
+ static isSupported(): boolean // Web Bluetooth support
+ static isQzSupported(): boolean // WebSocket support (not whether QZ Tray is running)
+ static isSerialSupported(): boolean // Web Serial support
+ static isUsbSupported(): boolean // WebUSB support
constructor(config?: WebEscposPrinterConfigInput)
onStatusChange(cb: (event: PrinterStatusEvent) => void): () => void
- connect(options?: { transport?: 'bluetooth'; compat?: boolean } | { transport: 'qz'; printerName?: string }): Promise
- listQzPrinters(query?: string): Promise // QZ Tray only — no native device picker, list then pick one
+ connect(options?:
+ | { transport?: 'bluetooth'; compat?: boolean; profile?: BluetoothPrinterProfile }
+ | { transport: 'qz'; printerName?: string }
+ | { transport: 'serial'; options?: SerialConnectOptions }
+ | { transport: 'usb'; profile?: UsbPrinterProfile }
+ ): Promise
+ listQzPrinters(query?: string): Promise
+ reconnectSerial(previous: SerialPortIdentity, options?: SerialConnectOptions): Promise
+ reconnectUsb(previous: UsbDeviceIdentity, profile?: UsbPrinterProfile): Promise
disconnect(): Promise
isConnected(): boolean
getPrinterInfo(): PrinterInfo | null
@@ -107,63 +105,44 @@ class WebEscposPrinter {
printReceipt(job: PrintJob): Promise
printRaw(bytes: Uint8Array | number[]): Promise
- renderPreview(job: PrintJob): Promise // no printer/connection needed — real scannable barcodes/QR/PDF417
+ renderPreview(job: PrintJob): Promise // no printer/connection needed
static renderPreview(job: PrintJob, config?: WebEscposPrinterConfigInput): Promise
}
```
-`PrintJob.content` is an ordered list of elements: `text`, `image`,
-`barcode`, `qrcode`, `pdf417`, `newline`, `rule`. See
-[src/types.ts](src/types.ts) for the full shape of each one. Errors arrive
-as a rejected Promise with a `.code`:
-`unsupported | user-gesture-required | connect-cancelled | connect-failed | not-connected | busy | print-failed`.
+`PrintJob.content` elements: `text`, `image`, `barcode`, `qrcode`,
+`pdf417`, `newline`, `rule` — see [src/types.ts](src/types.ts) for full
+shapes. Errors reject with a `.code`: `unsupported | user-gesture-required
+| connect-cancelled | connect-failed | not-connected | busy | print-failed`.
## Safe mode (compatibility fallback)
-Some elements support `safeMode: true`: instead of sending the printer's
-native ESC/POS command, the element is rendered using a safer fallback —
-for printers whose firmware doesn't support the native one. A general
-per-element pattern: `pdf417` (raster image), `qrcode` (raster image) and
-`rule` (plain ASCII `-` line) have it today; other elements (e.g. `text`,
-for printers with unreliable font/codepage support) may gain it later:
+`safeMode: true` renders an element via a safer fallback instead of its
+native ESC/POS command, for printers whose firmware mishandles the native
+one:
```ts
-{ type: 'pdf417', value: '...', safeMode: true }
-{ type: 'qrcode', value: '...', safeMode: true }
-{ type: 'rule', safeMode: true }
+{ type: 'pdf417', value: '...', safeMode: true } // raster image
+{ type: 'qrcode', value: '...', safeMode: true } // raster image
+{ type: 'rule', safeMode: true } // plain ASCII '-' line
```
-Off by default — the native command/character is smaller (or, for `rule`,
-just looks different — solid vs. dashed) and works fine on printers that
-already support it. Confirmed cases: some clone Bluetooth printers
-silently drop native PDF417
-([docs/notes/09-clone-printers-lack-native-pdf417.md](docs/notes/09-clone-printers-lack-native-pdf417.md))
-and mangle the native rule character into garbage
-([docs/notes/10-clone-printers-mangle-rule-character.md](docs/notes/10-clone-printers-mangle-rule-character.md))
-— both while an Epson prints the same bytes fine.
+Off by default. See
+[docs/notes/09](docs/notes/09-clone-printers-lack-native-pdf417.md) /
+[10](docs/notes/10-clone-printers-mangle-rule-character.md) for the
+confirmed clone-printer cases behind this.
## Connecting
-Default `connect()` restricts the Bluetooth device picker to recognized
-printer profiles:
-
-```ts
-await printer.connect() // must be called from a real user click/tap
-```
-
-If your printer doesn't show up, use compatibility mode — the picker
-lists every nearby device instead, matching the profile *after*
-connecting (reaches more hardware, noisier picker):
-
```ts
-await printer.connect({ compat: true })
+await printer.connect() // Bluetooth, restricted to known profiles — must be a real user click/tap
+await printer.connect({ compat: true }) // printer not showing up? broader picker, matches after connecting
```
### Manual Bluetooth profile
-If your printer isn't in this library's built-in profile table at all
-(or matches the wrong one), you don't need to fork/rebuild — pass your
-own profile directly, skipping the built-in table entirely:
+Printer not in the built-in table (or matching the wrong one)? Pass your
+own, no fork/rebuild needed:
```ts
import type { BluetoothPrinterProfile } from 'web-escpos-printer'
@@ -172,77 +151,90 @@ const myProfile: BluetoothPrinterProfile = {
filters: [{ services: ['000018f0-0000-1000-8000-00805f9b34fb'] }],
service: '000018f0-0000-1000-8000-00805f9b34fb',
characteristic: '00002af1-0000-1000-8000-00805f9b34fb',
+ language: 'esc-pos', // 'esc-pos' | 'star-prnt' | 'star-line'
+ codepageMapping: 'default', // forwarded as-is to ReceiptPrinterEncoder
+ // messageSize/sleepAfterCommand: optional BLE write pacing for printers that drop data
+}
+
+await printer.connect({ profile: myProfile }) // combine with { compat: true } too
+```
+
+Find `service`/`characteristic` with a BLE scanner app (nRF Connect,
+LightBlue) against the printer — most clones use a vendor-specific
+service.
+
+### Web Serial (USB cable, recommended default)
+
+Reliable across Windows/Linux/macOS, no extra software. Chrome/Edge
+desktop only.
+
+```ts
+const info = await printer.connect({ transport: 'serial' }) // shows the native port picker
+await printer.connect({ transport: 'serial', options: { baudRate: 19200 } }) // default: 9600 8N1, no flow control
+
+// skip the picker later — reconnect silently:
+const info2 = await printer.reconnectSerial({ usbVendorId: 0x0483, usbProductId: 0x5740 })
+if (!info2) await printer.connect({ transport: 'serial' })
+```
+
+A vendor's own "virtual COM port" tool (e.g. Epson's TM Virtual Port
+Assignment Tool) may not appear in the picker at all — not a filter bug,
+see [docs/notes/12](docs/notes/12-vendor-virtual-com-drivers-not-listed.md); use QZ Tray for those.
+
+### WebUSB (USB cable, works only when nothing else has claimed the device)
+
+Cleanest transport where it works, but not reliably available on any OS
+without freeing the device from another driver first — see
+[docs/notes/11](docs/notes/11-webusb-blocked-by-kernel-driver-claims.md).
+**Prefer Web Serial by default.**
+
+```ts
+const info = await printer.connect({ transport: 'usb' }) // picker restricted to a known vendor/product-id table
+
+// unlisted printer — same escape hatch as BluetoothPrinterProfile:
+import type { UsbPrinterProfile } from 'web-escpos-printer'
+const myProfile: UsbPrinterProfile = {
+ filters: [{ vendorId: 0x0483, productId: 0x5743 }],
+ configuration: 1,
+ interface: 0,
language: 'esc-pos',
codepageMapping: 'default',
}
-
-await printer.connect({ profile: myProfile })
-// also works combined with compat mode:
-await printer.connect({ profile: myProfile, compat: true })
+await printer.connect({ transport: 'usb', profile: myProfile })
```
-Field meanings:
-
-- `filters` — Web Bluetooth device-picker filters (same shape as
- `requestDevice({ filters })`), OR'd together. Restricts the picker in
- default mode; ignored (picker shows everything) in `compat: true` mode.
-- `service` / `characteristic` — the GATT service and characteristic
- UUIDs to write ESC/POS bytes to. Find these with a BLE scanner app (e.g.
- nRF Connect, LightBlue) against the target printer. If in doubt about
- what you're looking at, cross-check against the
- [Bluetooth GATT services specification](https://www.bluetooth.com/specifications/gatt/services)
- — most clone printers use a vendor-specific (non-standard) service, but
- a scanner may also show standard GATT services you should ignore.
-- `language` — `'esc-pos' | 'star-prnt' | 'star-line'`.
-- `codepageMapping` — forwarded as-is to `ReceiptPrinterEncoder`; use
- `'default'` if unsure.
-- `messageSize` / `sleepAfterCommand` — optional BLE write pacing (max
- bytes per chunk, delay between chunks) for printers that drop data
- under the default pacing. See
- [docs/notes/03-not-every-characteristic-supports-write-with-response.md](docs/notes/03-not-every-characteristic-supports-write-with-response.md)
- for background on why some printers need this at all.
-
-For USB or other OS-registered printers, connect through
-[QZ Tray](https://qz.io) instead (install the desktop app, pair your
-printer there first). QZ has no native device picker, so list printers
-yourself and pick one:
+`reconnectUsb({ serialNumber, vendorId, productId }, profile?)` mirrors
+`reconnectSerial()` for silent reconnect.
+
+### QZ Tray (fallback, any OS-registered printer)
```ts
const printerNames = await printer.listQzPrinters() // opens the QZ Tray session if needed
const info = await printer.connect({ transport: 'qz', printerName: printerNames[0] })
-// omit printerName to fall back to QZ Tray's own default printer
```
-QZ Tray shows its own permission popup per connect/print unless you
-configure its certificate/signature plumbing yourself (not done by this
-library — see [QZ Tray's docs](https://qz.io/wiki/2.0-signing-messages)).
-Windows-only caveat:
-[docs/notes/08-qz-windows-raw-driver-routing.md](docs/notes/08-qz-windows-raw-driver-routing.md).
+Requires the QZ Tray desktop app installed and the printer paired there.
+Shows its own permission popup per connect/print unless you configure its
+certificate/signature plumbing yourself. Windows caveat:
+[docs/notes/08](docs/notes/08-qz-windows-raw-driver-routing.md).
## Config
-Paper width, protocol and codepage can be set once at construction, or
-per print job — a per-job value always overrides the constructor's:
-
```ts
const printer = new WebEscposPrinter({
- paperWidth: '80mm', // '58mm' | '80mm' | '112mm' — shorthand for `columns` AND the image/barcode width ceiling
- language: 'star-prnt', // 'esc-pos' | 'star-prnt' | 'star-line', default 'esc-pos'
- codepageMapping: 'xprinter', // for non-standard clone printers; forwarded as-is to ReceiptPrinterEncoder
- printerModel: 'epson-tm-t88vi', // lets ReceiptPrinterEncoder auto-configure known-model defaults
- feedBeforeCut: 4, // blank lines fed before the physical cut, default 4
+ paperWidth: '80mm', // '58mm' | '80mm' | '112mm' — shorthand for columns + image/barcode width ceiling
+ 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
+ feedBeforeCut: 4, // blank lines fed before the cut, default 4
})
-// or per job:
-await printer.printReceipt({ paperWidth: '58mm', content: [...] })
+await printer.printReceipt({ paperWidth: '58mm', content: [...] }) // per-job overrides the constructor
```
-`language`/`codepageMapping` a Bluetooth profile reports on `PrinterInfo`
-is informational only — not applied to `printReceipt()` automatically. See
-[docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md](docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md)
-and
-[docs/notes/04-feedbeforecut-defaults-to-zero.md](docs/notes/04-feedbeforecut-defaults-to-zero.md)
-for why `paperWidth` and `feedBeforeCut` matter.
+See [docs/notes/02](docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md)
+/ [04](docs/notes/04-feedbeforecut-defaults-to-zero.md) for why
+`paperWidth`/`feedBeforeCut` matter.
## Building from source
@@ -251,14 +243,12 @@ npm install
npm run build # UMD + ESM + .d.ts (what gets published to npm)
npm run build:standalone # only build/web-escpos-printer.js
npm run build:dev # same as build, in watch mode
-npm test # node:test suite against the real encoder — see AGENTS.md's "Testing" section
+npm test # Vitest suite against the real encoder — see AGENTS.md's "Testing" section
```
## License
-MIT
-
-Note: the standalone UMD bundle (`build/web-escpos-printer.js`) statically
-includes [`qz-tray`](https://www.npmjs.com/package/qz-tray), licensed
-**LGPL-2.1** (every other bundled dependency is MIT). If you redistribute
-that bundle, check LGPL-2.1's compliance requirements.
+MIT. The standalone UMD bundle statically includes
+[`qz-tray`](https://www.npmjs.com/package/qz-tray), licensed
+**LGPL-2.1** (everything else bundled is MIT) — check LGPL-2.1's
+compliance requirements if you redistribute that bundle.
diff --git a/demo/app.js b/demo/app.js
index eafc7ea..1e91a34 100644
--- a/demo/app.js
+++ b/demo/app.js
@@ -4,8 +4,17 @@ const statusPill = document.getElementById('statusPill')
const logEl = document.getElementById('log')
const tabBluetooth = document.getElementById('tabBluetooth')
const tabQz = document.getElementById('tabQz')
+const tabSerial = document.getElementById('tabSerial')
+const tabUsb = document.getElementById('tabUsb')
const panelBluetooth = document.getElementById('panelBluetooth')
const panelQz = document.getElementById('panelQz')
+const panelSerial = document.getElementById('panelSerial')
+const panelUsb = document.getElementById('panelUsb')
+const serialBanner = document.getElementById('serialBanner')
+const usbBanner = document.getElementById('usbBanner')
+const connectSerialBtn = document.getElementById('connectSerialBtn')
+const serialBaudRateInput = document.getElementById('serialBaudRateInput')
+const connectUsbBtn = document.getElementById('connectUsbBtn')
const connectBtn = document.getElementById('connectBtn')
const connectCompatBtn = document.getElementById('connectCompatBtn')
const connectManualProfileBtn = document.getElementById('connectManualProfileBtn')
@@ -61,15 +70,22 @@ function setPill(el, state) {
el.className = `${PILL_BASE} ${PILL_STATE[state] || PILL_STATE.idle}`
}
-function selectTab(tab) {
- const bluetooth = tab === 'bluetooth'
- tabBluetooth.setAttribute('aria-selected', String(bluetooth))
- tabQz.setAttribute('aria-selected', String(!bluetooth))
- panelBluetooth.hidden = !bluetooth
- panelQz.hidden = bluetooth
+const TABS = {
+ bluetooth: [tabBluetooth, panelBluetooth],
+ qz: [tabQz, panelQz],
+ serial: [tabSerial, panelSerial],
+ usb: [tabUsb, panelUsb],
+}
+function selectTab(name) {
+ for (const [key, [tabEl, panelEl]] of Object.entries(TABS)) {
+ tabEl.setAttribute('aria-selected', String(key === name))
+ panelEl.hidden = key !== name
+ }
}
tabBluetooth.onclick = () => selectTab('bluetooth')
tabQz.onclick = () => selectTab('qz')
+tabSerial.onclick = () => selectTab('serial')
+tabUsb.onclick = () => selectTab('usb')
const supported = WebEscposPrinter.isSupported()
banner.textContent = supported ? 'Web Bluetooth supported' : 'Web Bluetooth not supported — use Chrome or Edge'
@@ -78,6 +94,16 @@ connectBtn.disabled = !supported
connectCompatBtn.disabled = !supported
connectManualProfileBtn.disabled = !supported
+const serialSupported = WebEscposPrinter.isSerialSupported()
+serialBanner.textContent = serialSupported ? 'Web Serial supported' : 'Web Serial not supported — use Chrome or Edge on desktop'
+setPill(serialBanner, serialSupported ? 'ok' : 'fail')
+connectSerialBtn.disabled = !serialSupported
+
+const usbSupported = WebEscposPrinter.isUsbSupported()
+usbBanner.textContent = usbSupported ? 'WebUSB supported' : 'WebUSB not supported — use Chrome or Edge on desktop'
+setPill(usbBanner, usbSupported ? 'ok' : 'fail')
+connectUsbBtn.disabled = !usbSupported
+
const STATUS_PILL_STATE = { connected: 'ok', printing: 'busy', connecting: 'busy', error: 'fail' }
const printer = new WebEscposPrinter()
@@ -99,6 +125,8 @@ printer.onStatusChange((event) => {
// gated by `supported` — only by having already connected/listed.
qzListBtn.disabled = connected
qzConnectBtn.disabled = connected || qzPrinterSelect.value === ''
+ connectSerialBtn.disabled = connected || !serialSupported
+ connectUsbBtn.disabled = connected || !usbSupported
})
connectBtn.onclick = async () => {
@@ -181,6 +209,27 @@ qzConnectBtn.onclick = async () => {
}
}
+connectSerialBtn.onclick = async () => {
+ const baudRate = serialBaudRateInput.value.trim()
+ const options = baudRate !== '' ? { baudRate: Number(baudRate) } : undefined
+
+ try {
+ const info = await printer.connect({ transport: 'serial', options })
+ log(`connected (Serial) to ${info.name}`)
+ } catch (error) {
+ log(`failed to connect (Serial): ${error.message}`)
+ }
+}
+
+connectUsbBtn.onclick = async () => {
+ try {
+ const info = await printer.connect({ transport: 'usb' })
+ log(`connected (USB) to ${info.name} (${info.language})`)
+ } catch (error) {
+ log(`failed to connect (USB): ${error.message}`)
+ }
+}
+
disconnectBtn.onclick = async () => {
await printer.disconnect()
log('disconnected')
diff --git a/demo/index.html b/demo/index.html
index 877b3cf..e9bfe6e 100644
--- a/demo/index.html
+++ b/demo/index.html
@@ -27,6 +27,12 @@
Connect
+
+
@@ -104,6 +110,37 @@
Connect
+
+ Checking support…
+
+ USB cable, virtual COM port — the reliable option across Windows/Linux/macOS with no extra software installed. Chromium-based browsers only (Chrome/Edge desktop).
+
+
+
+
+
+
+
+
+
+ Connects fine but the printer doesn't respond? The port list has no product names to go by (a Web Serial limitation) —
+ try the other COM port, or your printer's documented baud rate.
+ Printer only reachable through a vendor's own virtual-COM-port tool (e.g. Epson's TM Virtual Port Assignment Tool)?
+ It won't appear in this list at all — try the QZ Tray tab instead.
+
+
+
+
+ Checking support…
+
+ USB cable, direct bulk endpoint — cleanest transport where it works, but not reliable on any OS without extra setup (blocked the moment another driver has already claimed the device — a printer driver on Windows, the kernel's usblp module on Linux). Use Serial instead by default.
+
+
+
+
+
+
idle
diff --git a/docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md b/docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md
index 0fa2606..b09f3d5 100644
--- a/docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md
+++ b/docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md
@@ -1,23 +1,14 @@
# Gotcha #2: `paperWidth` must scale both `columns` and `imageMaxWidth`
-**`paperWidth` must scale both `columns` and `imageMaxWidth`**
-(`config.ts#PAPER_WIDTH_SPECS`) — `imageMaxWidth` also caps image
-resizing, the preview canvas width, and the barcode "too wide" check.
-Scaling only `columns` (an earlier bug) left `paperWidth: '80mm'` doing
-nothing for images/barcodes.
+`config.ts#PAPER_WIDTH_SPECS` scales both — `imageMaxWidth` also caps
+image resizing, preview canvas width, and the barcode "too wide" check.
+`PaperWidth`: `'58mm'|'80mm'|'112mm'`. `80mm` verified against real
+hardware (576 dots); `112mm` is an estimate.
-`PaperWidth` is `'58mm' | '80mm' | '112mm'` — `80mm` cross-checked against
-real hardware (576 dots); `112mm` is an estimate, not hardware-verified.
-
-**Known bug, found by the test suite below, not yet fixed**: `112mm` maps
-to `columns: 56`, but the real encoder's constructor only accepts columns
-of 32/35/42/44/48 (confirmed by reading the installed library) — it throws
-`"The width of the paper must me either 32, 35, 42, 44 or 48 columns"`.
-`paperWidth: '112mm'` therefore currently fails to build *any* receipt at
-all, not just images. `58mm`(32)/`80mm`(42) are unaffected (both valid).
-Pinned (as a currently-failing-on-purpose regression) by
-`test/Printer/ReceiptBuilder.pdf417.test.ts`'s "paperWidth" suite — flip
-that test to `doesNotReject` once this is actually fixed.
+**Open bug**: `112mm` → `columns: 56`, but the real encoder only accepts
+32/35/42/44/48 columns — throws, fails to build *any* receipt. `58mm`/
+`80mm` unaffected. Pinned (failing on purpose) by
+`ReceiptBuilder.pdf417.test.ts`'s "paperWidth" suite.
---
-Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #2).
+[AGENTS.md](../../AGENTS.md) gotcha #2.
diff --git a/docs/notes/03-not-every-characteristic-supports-write-with-response.md b/docs/notes/03-not-every-characteristic-supports-write-with-response.md
index 1941872..ac6b257 100644
--- a/docs/notes/03-not-every-characteristic-supports-write-with-response.md
+++ b/docs/notes/03-not-every-characteristic-supports-write-with-response.md
@@ -1,13 +1,10 @@
-# Gotcha #3: not every characteristic supports `writeValueWithResponse()`
+# Gotcha #3: not every BLE characteristic supports `writeValueWithResponse()`
-**Not every characteristic supports `writeValueWithResponse()`.**
-Confirmed on real hardware (MTP-II clone): its print characteristic
-only advertises `writeWithoutResponse`, so `writeValueWithResponse()`
-throws `NotSupportedError` (legacy DOMException `.code === 9`) on the
-first chunk. `writeChunked.ts`'s `pickWriter()` checks
-`characteristic.properties` and picks whichever method is actually
-supported (`write` preferred, `writeWithoutResponse` fallback) instead
-of assuming.
+Confirmed on an MTP-II clone: its print characteristic only advertises
+`writeWithoutResponse`, so `writeValueWithResponse()` throws
+`NotSupportedError` on the first chunk. `writeChunked.ts#pickWriter()`
+checks `characteristic.properties` and picks whichever method is
+actually supported instead of assuming.
---
-Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #3).
+[AGENTS.md](../../AGENTS.md) gotcha #3.
diff --git a/docs/notes/04-feedbeforecut-defaults-to-zero.md b/docs/notes/04-feedbeforecut-defaults-to-zero.md
index bee5ef0..8872da7 100644
--- a/docs/notes/04-feedbeforecut-defaults-to-zero.md
+++ b/docs/notes/04-feedbeforecut-defaults-to-zero.md
@@ -1,21 +1,14 @@
# Gotcha #4: `cut()` defaults `feedBeforeCut` to `0`
-**`cut()` defaults `feedBeforeCut` to `0`** unless a recognized
-`printerModel` supplies its own `cutter.feed` (confirmed:
-`feedBeforeCut = printerModel?.cutter?.feed || options.feedBeforeCut` —
-model's value always wins). With zero feed, the physical cutter fires
-before the last content clears it, slicing through it — confirmed on
-real hardware: an Epson TM-T20X-II via QZ Tray cut text/barcodes/PDF417
-too close or clean through, worse for taller elements. `epson-tm-t20x`
-isn't in the encoder's known-models table (`Unknown printer model`), so
-no auto-fallback — its closest relatives (`epson-tm-t20iii`/`iv`) use
-`cutter: { feed: 4 }`, also the single most common value across the
-whole table (21/30 models). `DEFAULT_CONFIG.feedBeforeCut = 4` for this
-reason — `ReceiptBuilder.ts` always passes it explicitly;
-`PreviewRenderer.ts` mirrors the same gap before its "✂ cut" mark (see
-AGENTS.md's "preview/print parity" rule in "Coding conventions").
+Zero feed lets the physical cutter slice through the last printed content
+before it clears — confirmed on a real Epson TM-T20X-II (QZ Tray).
+`epson-tm-t20x` isn't in the encoder's known-models table, so no
+auto-fallback applies. `4` is the most common `cutter.feed` value across
+the encoder's model table (21/30) — `DEFAULT_CONFIG.feedBeforeCut = 4`
+for that reason; `PreviewRenderer.ts` mirrors the same gap before its cut
+mark.
-Pinned by `test/config.test.ts` (`DEFAULT_CONFIG.feedBeforeCut` must stay `4`).
+Pinned by `test/config.test.ts`.
---
-Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #4).
+[AGENTS.md](../../AGENTS.md) gotcha #4.
diff --git a/docs/notes/05-bwip-js-validates-pdf417-capacity.md b/docs/notes/05-bwip-js-validates-pdf417-capacity.md
index 8884fef..3d25ab8 100644
--- a/docs/notes/05-bwip-js-validates-pdf417-capacity.md
+++ b/docs/notes/05-bwip-js-validates-pdf417-capacity.md
@@ -1,19 +1,13 @@
# Gotcha #5: bwip-js validates PDF417 capacity; the real encoder does not
-**bwip-js validates PDF417 capacity and throws when it doesn't fit; the
-real encoder does not validate at all.** Confirmed: encoding 2000 chars
-with `columns: 3` makes bwip-js reject with `pdf417insufficientCapacity`,
-while the real encoder happily emits ESC/POS bytes requesting that same
-impossible layout, leaving firmware behavior unverified. This is *why*
-`resolvePdf417Columns()` (`Preview/content/pdf417.ts`, AGENTS.md gotcha #1) exists: the
-shared capacity check both `ReceiptBuilder.ts` and `PreviewRenderer.ts`
-run before committing to a non-auto `columns`, falling back to
-fully-automatic (the pre-existing, safe behavior) when it doesn't fit.
-Never pass a fixed `columns` to the real encoder without this check.
+bwip-js throws (`pdf417insufficientCapacity`) when data doesn't fit a
+fixed `columns`; the real encoder emits the bytes anyway, with unverified
+firmware behavior. `resolvePdf417Columns()` (`Preview/content/pdf417.ts`)
+runs this capacity check before `ReceiptBuilder`/`PreviewRenderer` commit
+to a non-auto `columns`, falling back to automatic when it doesn't fit.
+Never pass a fixed `columns` to the real encoder without it.
-Pinned by `test/Preview/pdf417.raster.test.ts`'s `resolvePdf417Columns`
-suite (auto mode must fall back to `undefined` instead of forwarding an
-overflowing `columns`) and its own capacity-error propagation test.
+Pinned by `test/Preview/pdf417.raster.test.ts`.
---
-Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #5).
+[AGENTS.md](../../AGENTS.md) gotcha #5.
diff --git a/docs/notes/06-bwip-js-default-eclevel-differs.md b/docs/notes/06-bwip-js-default-eclevel-differs.md
index f2e66a8..db8c56c 100644
--- a/docs/notes/06-bwip-js-default-eclevel-differs.md
+++ b/docs/notes/06-bwip-js-default-eclevel-differs.md
@@ -1,18 +1,12 @@
-# Gotcha #6: bwip-js's default PDF417 `eclevel` differs from the real encoder's default `errorlevel`
+# Gotcha #6: bwip-js's default PDF417 `eclevel` differs from the real encoder's default
-**bwip-js's default PDF417 `eclevel` differs from the real encoder's
-default `errorlevel`.** Confirmed by decoding actual ESC/POS bytes: the
-real encoder, with `errorlevel` omitted, encodes level `1` (ASCII
-`"01"`); bwip-js, with `eclevel` omitted, renders exactly like its own
-`eclevel: 2`. Higher error-correction needs more codewords (more rows)
-for identical data/columns — a second, subtler cause of preview-vs-print
-shape mismatch that survived even after `columns` was aligned ([gotcha #5](05-bwip-js-validates-pdf417-capacity.md)). `Preview/content/pdf417.ts`'s `toBwipOptions()` now applies
-`DEFAULT_ERRORLEVEL` (`= 1`) uniformly whenever the job doesn't set
-`errorlevel`, so both `buildPdf417()` and `resolvePdf417Columns()`
-render/validate against the same level the real print already assumes.
+The real encoder defaults to `errorlevel: 1`; bwip-js's omitted `eclevel`
+renders like `eclevel: 2` — different error-correction means different
+row counts for identical data, a subtler preview/print shape mismatch
+than gotcha #5. `Preview/content/pdf417.ts#toBwipOptions()` applies
+`DEFAULT_ERRORLEVEL = 1` whenever the job doesn't set one.
-Pinned by `test/Printer/ReceiptBuilder.pdf417.test.ts` (an explicit
-`errorlevel: 1` must produce byte-identical output to leaving it unset).
+Pinned by `ReceiptBuilder.pdf417.test.ts`.
---
-Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #6).
+[AGENTS.md](../../AGENTS.md) gotcha #6.
diff --git a/docs/notes/07-bwip-js-generic-api-pulls-in-full-engine.md b/docs/notes/07-bwip-js-generic-api-pulls-in-full-engine.md
index 18c615d..3ce474f 100644
--- a/docs/notes/07-bwip-js-generic-api-pulls-in-full-engine.md
+++ b/docs/notes/07-bwip-js-generic-api-pulls-in-full-engine.md
@@ -1,32 +1,17 @@
-# Gotcha #7: `@bwip-js/browser`'s generic API pulls in its entire ~100-symbology engine
+# Gotcha #7: `@bwip-js/browser`'s generic API pulls in the full ~100-symbology engine
-**`@bwip-js/browser`'s generic `toCanvas()`/`toSVG()`/`render()` API
-resolves the symbology via a runtime string (`bcid`), which pulls its
-*entire* ~100-symbology engine into the bundle — always use the
-per-symbology named exports instead.** The generic API calls
-`bwipp_lookup(bcid)` internally, a dispatch table that references
-every bundled symbology; since `bcid` is a runtime string (not a
-static import), webpack can't prove which symbologies are actually
-reachable and can't tree-shake the rest. Confirmed with a real
-throwaway build: the generic pattern minified to 907 KiB; switching
-`Preview/content/pdf417.ts`/`code128.ts`/`itf.ts` to the named exports
-(`pdf417`/`code128`/`interleaved2of5`, each calling its own BWIPP
-encoder directly, bypassing `bwipp_lookup` entirely) dropped that to
-168 KiB combined — confirmed byte-identical behavior between the two
-(same capacity-check results, same errors) since both paths go
-through the same internal `_ToAny`/`_Render` machinery either way.
-For the SVG capacity-check path (`resolvePdf417Columns()`, no
-`