printrow.lastra.us is a label designer that prints against CSV data over Web Bluetooth. No drivers, no installs, no print server: design a label in the browser, bind {{variables}} to CSV columns, and batch-print to a KNAON Y50P or a NIIMBOT B1 thermal printer directly from the page.
- Canvas editor: multi-select, marquee, align and distribute, groups, layer reordering by drag, free rotation, undo/redo, and middle-drag panning.
- Elements: text (9 bundled fonts), barcodes and QR (7 symbologies via bwip-js), images, boxes, and any of lucide's ~1,750 icons through a searchable picker.
- CSV binding:
{{column}}resolves straight to a CSV column; no mapping step. Per-column formatting for text, numbers, currency (8 codes), and dates (7 patterns), stored on the template so it survives re-importing next week's file. - Batch printing: print one row or all of them, with a progress bar, cancel, and a printer status check between labels so a jam or open cover stops the run instead of spooling into the void.
Both printers speak proprietary framed binary protocols — the Y50P through yplib, the B1 through nblib — and neither has fonts or barcode symbologies. They accept nothing but a 1-bit bitmap. Everything is rendered host-side, which is what makes the preview trustworthy:
- A zod model is the single source of truth; Konva is a view of it. The same node builder feeds the editor canvas and the print rasterizer, so they cannot drift.
- The preview renders at the printer's real resolution: 203 dpi, hard 1-bit threshold, Atkinson dithering for photos. The dot grid on screen is the label, stair-steps and all.
- Barcodes snap their module grid to whole printer dots at any element size. Fractional modules are what make scaled barcodes both blurry and marginal under a scanner.
- Stock colour and die-cut shape are preview-only. The head burns black onto whatever stock is loaded; a coloured background reaching the thresholder would print the label solid black.
- Transport: BLE GATT writes in 20-byte chunks with pacing, with bidirectional status (ready / cover open / out of paper) polled between labels.
- One driver seam covers both printers, because below it they are shaped nothing alike: YPL streams a framed raster one way, NIIMBOT holds a request/response conversation with a reply expected per command. The app hands a driver a canvas; everything under that belongs to the protocol library.
Both wire formats are unforgiving in the same specific way, worth knowing if you fork this: a raster row that isn't the width the printer was told to expect shifts everything after it and can hang the firmware. Both libraries refuse to encode one rather than transmit it.
The two heads are not the same width. The Y50P prints 50 mm (400 dots); the B1 prints 48 mm (384 dots), despite taking stock sold as 50 mm. A 50 mm design is accepted on the B1 anyway: the driver trims 1 mm off each edge at print time, since that strip is bleed on every roll sold for it, and cropping keeps text and barcodes at their drawn size where scaling would not. Keep anything that matters a millimetre in from the long edges.
Print direction is a property of the label, not of the printer. It says which edge feeds first, which decides which of the design's two dimensions has to fit across the head; left rotates the raster a quarter turn. Pick the wrong one and the label prints sideways — invisible on square stock, obvious on anything else. Only the B1 exposes it; YPL always feeds top-first.
SvelteKit 2 · Svelte 5 runes · Tailwind 4 · shadcn-svelte · Konva · zod · bwip-js · lucide · yplib · nblib · bun
bun install bun run dev # http://localhost:5173 bun test src # CSV parsing, geometry, formatting, row ranges, icon search, printer fit bun run check # svelte-check
nixpacks.toml builds with Bun and starts the adapter-node server, so Coolify needs no extra configuration:
bun install --frozen-lockfile → bun run build → node build
There is no database and nothing to persist server-side (templates live in the browser's localStorage, and the printer is reached from the browser), so the server only serves assets and the SSR shell. It listens on PORT (the adapter defaults to 3000 on 0.0.0.0). Pushes to main redeploy automatically.
Serve it over HTTPS. Web Bluetooth only works in a secure context, so on a plain-http origin the Connect button cannot do anything at all.
Web Bluetooth is Chromium-only. Chrome on Linux additionally needs chrome://flags/#enable-web-bluetooth and a relaunch; #enable-web-bluetooth-new-permissions-backend is optional but lets a known printer reconnect with no scan. ChromeOS has it on by default. Everything except printing works in any modern browser.
Discovery deliberately filters by device name, never by service UUID: a service-UUID filter makes Chrome push a SetDiscoveryFilter UUID list to BlueZ, which segfaults bluetoothd 5.87 on desktop Linux.
Both printers below have printed real labels from this app.
Verified at 50 ×ばつ 30 mm stock, 8 dots/mm. Other media heights are safe, because the protocol never transmits height: the printer simply takes rows until the raster ends. Widths other than 50 mm follow the captured frame format but have not been tested on real stock.
This hardware is white-labelled, so the printer on your desk may carry a different name. The KNAON unit here and the sibling FlashToy U8 speak the same protocol, and the vendor Android apps that drive them all wrap the same com.j0data.sdk library. Branding is not the discriminator and neither is the USB vendor ID: 0x5958 is unregistered and shared with printers that speak TSPL instead. What settles it is the wire format. Frames that start 1a 01, end a1, and checksum as CRC-32 with init 0xCA896ADE are this protocol, whatever the label on the case says.
203 dpi with a 384-dot (48 mm) head — narrower than the 50 mm stock it takes, which is the thing that catches people out. Designs up to 50 mm are accepted and the outer millimetre on each side is dropped. Gap, black-mark and transparent stock, density 1–5. Black-mark stock starts the page 4 mm early, so the driver leads it with that much blank feed.
Stock without an RFID tag prints blank. The job is accepted and the label feeds, but the firmware drops density to near zero when it reads no tag, so the paper comes out unmarked. This is the printer, not a bug here: the print dialog warns when the heartbeat reports paper with no tag, and shows the firmware and hardware revision so you know which image fits if you go looking for a patched one. The no-flash workaround is to close the lid with a genuine tag held against the underside of the case, then take it away.
Confirmed printing with the top-edge feed direction. The left direction, and the full range of stock types and densities, follow the protocol but have not each been run on real hardware.
Have a different NIIMBOT? Only the B1 is supported here. For the rest of the family use niimblue, a browser label designer built on niimbluelib.
The Y50P protocol lives in yplib , reverse-engineered from HCI snoops of the manufacturer's Android app and verified by rebuilding whole print sessions byte for byte. Its FINDINGS.md is the full derivation.
The B1 protocol lives in nblib , a port of niimbluelib (MIT) by MultiMote — the reverse-engineering of the NIIMBOT format is entirely their work. nblib exists to be browser-only, dependency-free ESM that discovers by device name rather than by service UUID; its encoder is verified byte for byte against niimbluelib's across hundreds of rasters. Its ACKNOWLEDGEMENTS.md records what came from where.
Have a FlashToy U8? Go to Souukou/OpenBluetoothPrinter (MIT), a separate derivation of the same protocol (they call it YPL) that targets that printer directly. Three status bits in protocol.ts come from their work and are credited there.
Bundled and redistributed by this app:
- Lucide (ISC) for the icon set, ~1,750 marks embedded in the bundle.
- bwip-js (MIT) for barcode and QR generation.
- Konva (MIT) for canvas rendering, zod (MIT) for the template schema, svelte-dnd-action (MIT) for layer reordering, and shadcn-svelte / bits-ui (MIT) for the interface components.
- Typefaces, all under the SIL Open Font Licence 1.1, served from Fontsource: Inter, Pacifico, Oswald, Source Serif 4, JetBrains Mono, Bebas Neue, Archivo Black, Playfair Display, Space Grotesk, and Bricolage Grotesque for the wordmark.
MIT