<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Azeem Ullah</title>
    <description>The latest articles on DEV Community by Azeem Ullah (@azeem_ullah).</description>
    <link>https://dev.to/azeem_ullah</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2517951%2Ff95da6a1-c619-48ad-b259-41ef7d64098d.png</url>
      <title>DEV Community: Azeem Ullah</title>
      <link>https://dev.to/azeem_ullah</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/azeem_ullah"/>
    <language>en</language>
    <item>
      <title>Printing shipping and product labels from a web app (ZPL and TSPL, no PDF)</title>
      <dc:creator>Azeem Ullah</dc:creator>
      <pubDate>Fri, 09 Oct 2026 09:41:36 +0000</pubDate>
      <link>https://dev.to/azeem_ullah/printing-shipping-and-product-labels-from-a-web-app-zpl-and-tspl-no-pdf-4mmc</link>
      <guid>https://dev.to/azeem_ullah/printing-shipping-and-product-labels-from-a-web-app-zpl-and-tspl-no-pdf-4mmc</guid>
      <description>&lt;p&gt;If your web app ships parcels, prices products or tracks stock, sooner or later someone asks: &lt;em&gt;"Can it print the label straight to the Zebra?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The usual first attempt is to generate a PDF and call &lt;code&gt;window.print()&lt;/code&gt;. That works badly. The label comes out scaled, shifted or split across two labels, and every PC needs its own driver settings. Label printers have their own languages, and sending those directly is simpler and much more reliable.&lt;/p&gt;

&lt;p&gt;This post covers the two languages you'll meet most (ZPL and TSPL), the units and sizes that trip people up, and how to get the bytes from a browser to the printer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two languages cover most printers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;Printers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ZPL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Zebra, and most printers sold as "Zebra-compatible"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TSPL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TSC, 4BARCODE, many Xprinter and generic 4" label printers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EPL&lt;/td&gt;
&lt;td&gt;older Zebra/Eltron models (avoid for new work)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both are plain text. You build a string, send it to the printer as-is (raw), and the printer draws the label itself. No driver rendering, no PDF, no scaling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Units: everything is in dots
&lt;/h2&gt;

&lt;p&gt;This is where most first labels go wrong. Coordinates are in &lt;strong&gt;printer dots&lt;/strong&gt;, not millimetres or pixels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;203 dpi&lt;/strong&gt; printers (the common ones): 8 dots per mm&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;300 dpi&lt;/strong&gt; printers: about 11.8 dots per mm&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a 4 × 6 inch shipping label is 812 × 1218 dots at 203 dpi, and 1200 × 1800 at 300 dpi. If your label prints at two-thirds size, you wrote it for 203 dpi and sent it to a 300 dpi printer (or the other way round). Keep the dpi in your printer settings and multiply.&lt;/p&gt;

&lt;h2&gt;
  
  
  ZPL: a shipping label
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;^XA
^PW812
^LL1218
^CI28
^CF0,40
^FO40,40^FDShip to: Jane Doe^FS
^CF0,30
^FO40,90^FD221B Baker Street, London^FS
^FO40,150^BY3^BCN,120,Y,N,N^FD1Z999AA10123456784^FS
^FO40,320^BQN,2,6^FDQA,https://example.com/track/1Z999AA10123456784^FS
^XZ
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the commands do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;^XA&lt;/code&gt; … &lt;code&gt;^XZ&lt;/code&gt;: start and end of a label&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;^PW812&lt;/code&gt; / &lt;code&gt;^LL1218&lt;/code&gt;: print width and label length in dots (4 × 6" at 203 dpi)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;^CI28&lt;/code&gt;: treat field data as UTF-8, so &lt;code&gt;é&lt;/code&gt;, &lt;code&gt;ü&lt;/code&gt; and &lt;code&gt;£&lt;/code&gt; work with the built-in scalable font&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;^CF0,40&lt;/code&gt;: default font 0 (scalable), 40 dots high&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;^FOx,y&lt;/code&gt;: field origin (top-left of the next element)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;^FD…^FS&lt;/code&gt;: field data, then field separator&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;^BY3^BCN,120,Y,N,N&lt;/code&gt;: Code 128 barcode, module width 3, 120 dots high, human-readable text underneath&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;^BQN,2,6&lt;/code&gt; + &lt;code&gt;^FDQA,…&lt;/code&gt;: QR code, magnification 6. The &lt;code&gt;QA,&lt;/code&gt; prefix means error-correction level Q and automatic input mode. It isn't part of your data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; paste ZPL into the free &lt;a href="https://labelary.com/viewer.html" rel="noopener noreferrer"&gt;Labelary viewer&lt;/a&gt; to see the label before you print a single one. It saves a lot of paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  TSPL: a product label
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SIZE 50 mm, 30 mm
GAP 2 mm, 0 mm
DIRECTION 1
CLS
TEXT 20,20,"3",0,1,1,"Organic Honey 500g"
TEXT 20,60,"2",0,1,1,"Price: 9.99"
BARCODE 20,100,"128",70,1,0,2,2,"SKU-000123"
PRINT 1,1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SIZE&lt;/code&gt; / &lt;code&gt;GAP&lt;/code&gt;: label size and the gap between labels. TSPL lets you use mm here, but &lt;code&gt;TEXT&lt;/code&gt; and &lt;code&gt;BARCODE&lt;/code&gt; positions are still in dots.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CLS&lt;/code&gt;: clear the image buffer. Forget it and you'll print the previous label on top of the new one.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TEXT x,y,"font",rotation,x-mul,y-mul,"content"&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BARCODE x,y,"128",height,readable,rotation,narrow,wide,"content"&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;QRCODE x,y,M,4,A,0,"https://…"&lt;/code&gt; adds a QR code (ECC level M, cell width 4).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PRINT 1,1&lt;/code&gt;: print one copy of one set&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;End each line with &lt;code&gt;\r\n&lt;/code&gt;. Many printers accept &lt;code&gt;\n&lt;/code&gt;, but some older firmware doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wrong gap/label length = blank labels or a label skipped every time.&lt;/strong&gt; Calibrate the media sensor once. On Zebra, hold the feed button or send &lt;code&gt;~JC&lt;/code&gt;. On TSC, use the printer's utility or the &lt;code&gt;AUTODETECT&lt;/code&gt; command. Then make sure &lt;code&gt;^LL&lt;/code&gt; / &lt;code&gt;SIZE&lt;/code&gt; match the real stock.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in fonts are limited.&lt;/strong&gt; ZPL's font 0 plus &lt;code&gt;^CI28&lt;/code&gt; handles most Latin text. TSPL's bitmap fonts are often ASCII-only. For Arabic, Cyrillic or CJK, either install a font on the printer or &lt;strong&gt;draw the text as an image&lt;/strong&gt; in the browser (a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;), then send it with ZPL &lt;code&gt;^GFA&lt;/code&gt; or TSPL &lt;code&gt;BITMAP&lt;/code&gt;. Treat the image as 1-bit and threshold it, because label printers don't do greys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't send ZPL through a "normal" print job.&lt;/strong&gt; On Windows, a job submitted through the driver with datatype &lt;code&gt;RAW&lt;/code&gt; (&lt;code&gt;OpenPrinter&lt;/code&gt; → &lt;code&gt;StartDocPrinter&lt;/code&gt; with &lt;code&gt;"RAW"&lt;/code&gt; → &lt;code&gt;WritePrinter&lt;/code&gt;) passes your ZPL through untouched. Printing a text file through the driver instead prints the &lt;em&gt;ZPL source code&lt;/em&gt; as text on the label. On macOS/Linux, &lt;code&gt;lp -d &amp;lt;printer&amp;gt; -o raw label.zpl&lt;/code&gt; does the right thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network label printers listen on TCP port 9100.&lt;/strong&gt; &lt;code&gt;nc 192.168.1.60 9100 &amp;lt; label.zpl&lt;/code&gt; is the fastest way to test one. Browsers can't open raw TCP sockets, though, which brings us to…&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Getting the bytes from the browser to the printer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Option 1: the vendor's agent
&lt;/h3&gt;

&lt;p&gt;Zebra ships a free local agent called &lt;strong&gt;Browser Print&lt;/strong&gt;, with a JS library. If every printer you'll ever support is a Zebra, use it. It doesn't help with TSC and the many generic TSPL printers, and you'll need a separate solution for receipt printers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: WebUSB
&lt;/h3&gt;

&lt;p&gt;Chromium-only, needs a user gesture to pair, and on Windows the installed driver usually holds the USB interface, so the browser can't claim it. It's fine for a kiosk where you control the machine, and painful anywhere else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 3: your own small local agent
&lt;/h3&gt;

&lt;p&gt;A program on the PC listens on &lt;code&gt;127.0.0.1&lt;/code&gt;, your page &lt;code&gt;POST&lt;/code&gt;s the ZPL/TSPL string to it, and it writes the bytes to the OS print queue as RAW (or to TCP 9100 for network printers). It's about a hundred lines of code, plus the parts people skip:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;answer Chrome's Local Network Access preflight with &lt;code&gt;Access-Control-Allow-Private-Network: true&lt;/code&gt;. If you pass &lt;code&gt;targetAddressSpace&lt;/code&gt; to &lt;code&gt;fetch&lt;/code&gt;, use &lt;code&gt;'loopback'&lt;/code&gt; for 127.0.0.1. In Chrome 155, &lt;code&gt;'local'&lt;/code&gt; fails.&lt;/li&gt;
&lt;li&gt;bind to &lt;code&gt;127.0.0.1&lt;/code&gt; only, check the &lt;code&gt;Origin&lt;/code&gt; header against an allowlist, and reject non-loopback &lt;code&gt;Host&lt;/code&gt; headers (DNS rebinding)&lt;/li&gt;
&lt;li&gt;lock per printer so two tabs don't interleave two labels&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote up the receipt-printer side of this (ESC/POS, printer widths, raster images) in &lt;a href="https://dev.to/azeem_ullah/printing-receipts-silently-from-a-web-app-what-actually-works-in-2026-cm5"&gt;a previous post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you'd rather not build the agent
&lt;/h2&gt;

&lt;p&gt;I packaged that agent as &lt;strong&gt;Thermalink&lt;/strong&gt;: a single ~7 MB binary for Windows/macOS/Linux plus a JS SDK. For label printers it's a pure raw passthrough: you write the ZPL/TSPL, and it gets the bytes to the right printer (USB via the OS queue, or network via 9100). The same agent also does receipt printers, where it renders receipts as images.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Thermalink&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;printRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zpl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;printer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;labels&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// 'labels' = alias for the Zebra/TSC queue or tcp://ip:9100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To be upfront: I've tested it on a real BIXOLON receipt printer, and the raw label path is covered by automated tests, but I haven't tested it on a physical label printer yet. If you try it on one, I'd like to hear how it went. It's a paid one-time download with full source: &lt;a href="https://7207381545467.gumroad.com/l/xwyogp" rel="noopener noreferrer"&gt;https://7207381545467.gumroad.com/l/xwyogp&lt;/a&gt;. &lt;em&gt;Disclosure: I built it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Everything above works without it too. If you're stuck on a specific label printer, drop the model and your ZPL/TSPL in the comments and I'll take a look.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Printing receipts silently from a web app: what actually works in 2026</title>
      <dc:creator>Azeem Ullah</dc:creator>
      <pubDate>Fri, 09 Oct 2026 08:03:46 +0000</pubDate>
      <link>https://dev.to/azeem_ullah/printing-receipts-silently-from-a-web-app-what-actually-works-in-2026-cm5</link>
      <guid>https://dev.to/azeem_ullah/printing-receipts-silently-from-a-web-app-what-actually-works-in-2026-cm5</guid>
      <description>&lt;p&gt;If you've built a POS, restaurant or warehouse web app, you've hit the receipt-printer wall: the browser wants to show a print dialog, the cashier wants the receipt &lt;em&gt;now&lt;/em&gt;, and the printer wants raw bytes.&lt;/p&gt;

&lt;p&gt;Here's what I learned getting a web page to print to a thermal receipt printer with no dialog, including two gotchas that cost me real time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: &lt;code&gt;window.print()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Works everywhere, but:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it always shows a dialog (outside kiosk mode)&lt;/li&gt;
&lt;li&gt;it adds page margins and scaling you don't control&lt;/li&gt;
&lt;li&gt;it can't &lt;strong&gt;cut the paper&lt;/strong&gt; or &lt;strong&gt;open the cash drawer&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fine for the occasional print, painful at a busy counter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: WebUSB / Web Serial
&lt;/h2&gt;

&lt;p&gt;No install, but it's Chromium-only, needs a user gesture to pair the device, and on Windows it fights the installed printer driver for the USB interface. It's OK for kiosks you fully control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: a local agent (what most POS apps end up doing)
&lt;/h2&gt;

&lt;p&gt;A small program on the shop PC listens on &lt;code&gt;127.0.0.1&lt;/code&gt;, and your page calls it with &lt;code&gt;fetch()&lt;/code&gt;. It talks to the printer through the OS print queue or a network socket. Three things to get right:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Chrome's Local Network Access rules
&lt;/h3&gt;

&lt;p&gt;An &lt;code&gt;https://&lt;/code&gt; site calling &lt;code&gt;http://127.0.0.1&lt;/code&gt; triggers a CORS preflight that must be answered with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Access-Control-Allow-Private-Network: true
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if you pass the newer &lt;code&gt;targetAddressSpace&lt;/code&gt; fetch option, it has to match the real address space. In Chrome 155, &lt;code&gt;targetAddressSpace: 'local'&lt;/code&gt; for a loopback address makes the fetch &lt;strong&gt;fail outright&lt;/strong&gt;. &lt;code&gt;'loopback'&lt;/code&gt; (or no option) works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://127.0.0.1:17777/v1/health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;targetAddressSpace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loopback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Security
&lt;/h3&gt;

&lt;p&gt;Anything listening on localhost can be called by &lt;em&gt;any&lt;/em&gt; website the user opens. At minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;bind to &lt;code&gt;127.0.0.1&lt;/code&gt; only, never &lt;code&gt;0.0.0.0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;check the &lt;code&gt;Origin&lt;/code&gt; header against an allowlist (browsers set it, and pages can't fake it)&lt;/li&gt;
&lt;li&gt;reject requests whose &lt;code&gt;Host&lt;/code&gt; header isn't a loopback name, which blocks DNS-rebinding attacks&lt;/li&gt;
&lt;li&gt;require an API key for non-browser callers (no &lt;code&gt;Origin&lt;/code&gt; header)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Printer width is never what the spec sheet says
&lt;/h3&gt;

&lt;p&gt;This one surprised me. "80 mm" printers differ in DPI (180 vs 203), some have a 58 mm mode switch, and they all keep their own margins. My BIXOLON SRP-352plusIII is &lt;strong&gt;576 dots&lt;/strong&gt; wide on paper (the spec sheet), but in practice only &lt;strong&gt;568 dots&lt;/strong&gt; print. Text at the very right edge got clipped.&lt;/p&gt;

&lt;p&gt;If you build receipts out of fixed 48-character text lines, they'll wrap or clip differently on the next printer you meet. The approach that fixed it for me: &lt;strong&gt;render the receipt as a bitmap at the printer's measured width&lt;/strong&gt; (real TTF fonts, measured columns, QR codes, barcodes) and send it as an ESC/POS raster image. Then it looks the same on every brand.&lt;/p&gt;

&lt;p&gt;How to measure: print a ruler image 640 dots wide with a tick every 8 dots and a number every 32 dots, each number ending at its tick. The last fully readable number (+8 per extra tick) is the usable width.&lt;/p&gt;

&lt;h2&gt;
  
  
  ESC/POS cheat sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Initialize&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1B 40&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial cut&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1D 56 01&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kick cash drawer (pin 2)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1B 70 00 19 FA&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raster image&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;1D 76 30 00 xL xH yL yH&lt;/code&gt; + 1 bit per dot, MSB first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tips:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Send raster images in bands of about 128 rows. Cheap printers overflow their buffer on one huge image.&lt;/li&gt;
&lt;li&gt;On Windows, send bytes through the spooler with datatype &lt;code&gt;RAW&lt;/code&gt; (&lt;code&gt;OpenPrinter&lt;/code&gt; → &lt;code&gt;StartDocPrinter&lt;/code&gt; with &lt;code&gt;"RAW"&lt;/code&gt; → &lt;code&gt;WritePrinter&lt;/code&gt;). Then any installed driver works, and it won't re-render your bytes.&lt;/li&gt;
&lt;li&gt;On macOS/Linux, &lt;code&gt;lp -d &amp;lt;printer&amp;gt; -o raw&lt;/code&gt; does the same through CUPS.&lt;/li&gt;
&lt;li&gt;Network printers almost always accept raw bytes on TCP port &lt;strong&gt;9100&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you'd rather not build it
&lt;/h2&gt;

&lt;p&gt;I packaged all of the above as &lt;strong&gt;Thermalink&lt;/strong&gt;: a ~7 MB agent for Windows/macOS/Linux plus a small JS SDK. It has image mode (and a text mode for slow links), a &lt;code&gt;calibrate&lt;/code&gt; command that prints that ruler, QR/barcodes/logos, cash drawer, label printers (ZPL/TSPL), and full source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Thermalink&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;Thermalink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BLUE DOOR CAFE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Flat white x2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;8.00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TOTAL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;8.00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;qr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com/r/A1B2C3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cut&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a paid, one-time download: &lt;a href="https://7207381545467.gumroad.com/l/xwyogp" rel="noopener noreferrer"&gt;https://7207381545467.gumroad.com/l/xwyogp&lt;/a&gt;. &lt;em&gt;Disclosure: I built it.&lt;/em&gt; Everything in this article works without it too. Happy to answer ESC/POS questions in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>pos</category>
    </item>
  </channel>
</rss>
