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
289 changes: 124 additions & 165 deletions AGENTS.md

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# Stage 1: builds the wrapper with webpack (Node only here, at build-time).
FROM node:20-alpine AS build
FROM node:20-slim AS build
WORKDIR /app

COPY package.json package-lock.json ./
Expand Down
250 changes: 120 additions & 130 deletions README.md

Large diffs are not rendered by default.

61 changes: 55 additions & 6 deletions demo/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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'
Expand All @@ -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()
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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')
Expand Down
37 changes: 37 additions & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ <h2 class="text-sm font-semibold mb-3">Connect</h2>
<button
class="border-0 bg-transparent px-1 py-2 mr-4 border-b-2 border-transparent text-gray-500 dark:text-gray-400 font-semibold text-sm cursor-pointer aria-selected:text-gray-900 dark:aria-selected:text-gray-100 aria-selected:border-blue-600"
id="tabQz" role="tab" aria-selected="false">QZ Tray</button>
<button
class="border-0 bg-transparent px-1 py-2 mr-4 border-b-2 border-transparent text-gray-500 dark:text-gray-400 font-semibold text-sm cursor-pointer aria-selected:text-gray-900 dark:aria-selected:text-gray-100 aria-selected:border-blue-600"
id="tabSerial" role="tab" aria-selected="false">Serial</button>
<button
class="border-0 bg-transparent px-1 py-2 mr-4 border-b-2 border-transparent text-gray-500 dark:text-gray-400 font-semibold text-sm cursor-pointer aria-selected:text-gray-900 dark:aria-selected:text-gray-100 aria-selected:border-blue-600"
id="tabUsb" role="tab" aria-selected="false">USB</button>
</div>

<div id="panelBluetooth">
Expand Down Expand Up @@ -104,6 +110,37 @@ <h2 class="text-sm font-semibold mb-3">Connect</h2>
</div>
</div>

<div id="panelSerial" hidden>
<span id="serialBanner" class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold">Checking support…</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
USB cable, virtual COM port — the reliable option across Windows/Linux/macOS with no extra software installed. Chromium-based browsers only (Chrome/Edge desktop).
</p>
<div class="flex flex-wrap gap-2 items-end mt-3">
<div>
<label for="serialBaudRateInput" class="block mb-1 text-sm font-semibold">Baud rate</label>
<input type="number" id="serialBaudRateInput" min="1" placeholder="Default (9600)"
class="w-32 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>
<button id="connectSerialBtn" class="rounded-lg px-4 py-2 text-sm font-medium cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed bg-blue-600 dark:bg-blue-500 text-white">Connect</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
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.
</p>
</div>

<div id="panelUsb" hidden>
<span id="usbBanner" class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold">Checking support…</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
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.
</p>
<div class="flex flex-wrap gap-2 items-center mt-3">
<button id="connectUsbBtn" class="rounded-lg px-4 py-2 text-sm font-medium cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed bg-blue-600 dark:bg-blue-500 text-white">Connect</button>
</div>
</div>

<div class="flex flex-wrap gap-2 items-center border-t border-gray-200 dark:border-neutral-800 mt-4 pt-3">
<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>
Expand Down
27 changes: 9 additions & 18 deletions docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 9 additions & 16 deletions docs/notes/04-feedbeforecut-defaults-to-zero.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 8 additions & 14 deletions docs/notes/05-bwip-js-validates-pdf417-capacity.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 8 additions & 14 deletions docs/notes/06-bwip-js-default-eclevel-differs.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 13 additions & 28 deletions docs/notes/07-bwip-js-generic-api-pulls-in-full-engine.md
Original file line number Diff line number Diff line change
@@ -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
`<canvas>` available), use `drawingSVG()` as the drawing argument —
`pdf417(opts, drawingSVG())` — instead of `toSVG({bcid: 'pdf417', ...})`.
Note `RenderOptions` still requires a `bcid` field on the options
object for typing purposes even when calling a named export directly —
it's structurally mandatory but never actually read on this path, so
keep it, just don't call `toCanvas`/`toSVG`/`render` with it.
`toCanvas()`/`toSVG()`/`render()` resolve the symbology via a runtime
string (`bcid`) through `bwipp_lookup()`, so webpack can't tree-shake
anything — confirmed: 907 KiB minified. Always use the per-symbology
named exports instead (`pdf417`/`code128`/`interleaved2of5`, each calling
its own encoder directly) — same behavior, 168 KiB combined. For the
capacity-check path with no `<canvas>`, pass `drawingSVG()` as the
drawing arg instead of `toSVG()`. `RenderOptions.bcid` is still
structurally required for typing even when unused this way — keep it,
just don't call the generic API with it.

bwip-js was chosen for `code128`/`itf`/`pdf417` preview over hand-rolled
encoders for correctness, not just size: it auto-selects Code128 Subsets
A/B/C, where the hand-rolled version this project used to have only did
Subset B.
bwip-js was chosen over hand-rolled encoders for correctness too — it
auto-selects Code128 Subsets A/B/C.

---
Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #7).
[AGENTS.md](../../AGENTS.md) gotcha #7.
Loading
Loading