@12-apps/thermal-printing

中文文档
1.4.0发布于 2026/10/19MITES Module零依赖构建来源已验证近 30 天下载 3.3万

Receipt printing for the two ways a thermal printer is attached, behind ONE fixed-width document. Isomorphic line model and wrapping (./), four type sizes, inverted bands, boxes, label/amount rows and raster images, the ESC/POS encoder with its CP850 code

原始使用文档
文档目录13 节
  1. The problem it solves
  2. What it does not do
  3. Surfaces
  4. The vocabulary: four sizes, bands, boxes, rows, pictures
  5. The four sizes, and how they map onto ESC/POS
  6. Bands and boxes
  7. Rows
  8. Pictures
  9. Getting a picture down to one bit ( ./raster )
  10. Finding a printer nobody wrote the address of down
  11. Packaging the helper
  12. The three things that are easy to get wrong
  13. Adoption

@12-apps/thermal-printing

Receipt printing for the two ways a thermal printer is attached, behind one fixed-width document.

pnpm add @12-apps/thermal-printing

The problem it solves

A receipt printer reaches a host one of two ways, and the host does not get to choose which one the shop bought:

Network (Wi-Fi / Ethernet)Local (USB cable)
how it is reachedraw ESC/POS over a TCP socket, :9100 by conventionthe browser on the machine it is plugged into, printing through the OS
who drives itthe server — ./neta tab — ./html
with nothing openstill printsstops, silently: a USB device has no address
what a success meansthe bytes left the processthe document reached the operating system

The naive shape is two code paths, and the cost shows up the first time a store swaps one kind of printer for the other: the ticket changes, because a second path is a second layout. So the split here is one document, two encoders.

       host builds            this package encodes
   ┌──────────────────┐      ┌─────────────────────┐
   │  TicketLine[]    │─────▶│ ./escpos → bytes    │──▶ socket (./net)
   │  (line, field,   │      │ ./html   → document │──▶ a tab prints it
   │   rule, wrap)    │      └─────────────────────┘
   └──────────────────┘

The layout is authoritative for both. ./html renders the same lines in a monospace column of exactly the same width rather than laying out a <table> — that is the divergence the package exists to refuse.

What it does not do

It carries no copy. Not one sentence, in any language. A failed send returns a reason code and the host:port it was aimed at; the host turns that into words, in the language of whoever is reading the screen. Two hosts can say "the printer at 10.0.0.9 did not answer" and "the kitchen printer is offline" from the same event.

It does not compose your ticket. What a ticket says, in what order, is the host's product decision — and it is where the host's own vocabulary, currency and time zone live. This package gives you the arithmetic underneath it: column counts, wrapping with a hanging indent, dividers, Label: value fields that disappear when there is no value.

It owns no models and no routes. There is no Prisma partial and nothing to mount.

Surfaces

ExportWhat it is
@12-apps/thermal-printingThe line model — TicketLine, columnsFor, wrap, line, centered, rule, field, PAPER_WIDTHS_MM, and the reference-sheet vocabulary: textLines, band, box, row, image, LINE_SIZES, printableDotsFor, textWidth. Pure, isomorphic.
…/escposencodeTicket(lines) → Uint8Array. Initialise, CP850, align, emphasise, font and magnification, reverse, line spacing, GS v 0 raster, feed, partial cut. encodeDrawerPulse(options?) → the five-byte ESC p m t1 t2 that opens the cash drawer wired to the printer, alone or appended to a ticket.
…/htmlrenderTicketHtml(lines, paperWidthMm, lang?) → a standalone document exactly the roll's printable width (72 mm of 80, 48 mm of 58), pure black on white, with @page { margin: 0 }. rasterToDataUri(raster) → the 1-bit picture as a data: URI, for a preview.
…/rasterrasterizeSvg(svg, { width }) → RGBA at the printer's dot size; toMonochrome(rgba, options?) → a printable 1-bit RasterImage. Pure, isomorphic, no canvas and no native module.
…/netsendToNetworkPrinter(host, port, bytes, options?). Node-only (node:net), never throws, structured failures.
…/routingprinterRoute(printers) / printerFor(route, destinationId) — per-destination with a default, generic over your own printer rows.
…/discoveryscanForPrinters(options?) / probePrinter(host, port?) — find the address nobody wrote down. Node-only.
…/discovery/bridgestartDiscoveryBridge({ allowedOrigins }) — a loopback server an HTTPS page may call to run that scan. Node-only.
…/electronprintHtml({ html, deviceName, paperWidthMm?, session? }) — an offscreen window, a silent print to a named device (or the default), destroyed after. The page is always named and is the roll: paperWidthMm wide (omitted: the narrowest roll whose printable width holds the rendered body) and as tall as the ticket, placed at the printable area. Without it Electron asks the driver for A4 (FUT-3009). A refusal rejects with PrintRefusedError (reason: string | null, never a sentence). electron is an optional peer.
…/discovery/clirunFinder({ allowedOrigins }) / main(argv, env) — the helper a merchant downloads, as a function to bundle. Node-only.

./net and ./discovery sit behind their own subpaths so that importing the encoders into a browser bundle never drags node:net in, and ./electron behind its own so that nothing else ever imports electron.

The vocabulary: four sizes, bands, boxes, rows, pictures

A receipt is more than one size of text. The builders below cover what a designed ticket uses, and every one of them produces plain text plus a flag, so the two encoders print the same characters in the same places: a row is padded here, a box is drawn here, a band is centred here.

import { band, box, image, row, rule, textLines, columnsFor } from '@12-apps/thermal-printing';

const w = printer.paperWidthMm; // 58 or 80
const lines = [
  image(logo),                                                     // a RasterImage, centred
  ...textLines(copy.headline, w, { size: 'xlarge', bold: true, align: 'center' }),
  ...band(copy.paidBanner, w),                                     // white on black, edge to edge
  ...row(item.label, money(item.totalCents), w, { indent: 3 }),    // amount flush right
  rule(columnsFor(w)),
  ...row(copy.total, money(totalCents), w, { size: 'large', bold: true }),
  ...box(copy.bringChange, w),                                     // ┌──┐ │ … │ └──┘
  ...textLines(copy.footer, w, { size: 'small', align: 'center' }),
];

The size-aware builders take the paper width, not a column count: with four sizes there are four counts per roll, and the builder knows which one its size needs. The legacy line, centered, rule and field are unchanged, and a ticket that uses only them encodes to exactly the bytes it did before.

The four sizes, and how they map onto ESC/POS

ESC/POS has no point sizes — it has two built-in fonts and integer magnification. Each size is one font at one multiplier, all four reachable with ESC ! alone (the part of the command set every printer in this class has):

sizereference stepfontmagnificationcell (dots)glyph heightcolumns 58 mmcolumns 80 mm
smallP — detailsB1 × 19 × 17≈ 6 pt4264
mediumM — textA1 × 112 × 24≈ 8.5 pt3248
largeG — highlights (TOTAL)B2 × 218 × 34≈ 12 pt2132
xlargeGG — headlineA2 × 224 × 48≈ 17 pt1624

medium and large match 8.5 pt and 12 pt almost exactly at 8 dots/mm. small is Font B, the only step below Font A. xlarge is the biggest step reachable without GS !; the next one (3 × 3) would leave 16 columns on 80 mm and 10 on 58 mm, which cannot hold a typical headline on the narrow roll.

Why these sizes use double width when double never does. The legacy double emphasis stays double height only, because a line wrapped at 48 columns and printed twice as wide silently rewraps at 24. That danger is a column count disagreeing with a magnification. A sized line carries its size, columnsFor(paperWidthMm, size) answers for it, and the builders wrap at that count — so width and height are magnified together, and a glyph that is only taller (which reads as condensed) is avoided.

The HTML document reproduces the column counts rather than the point sizes: the body is the printable width in millimetres (72 mm on 80 mm, 48 mm on 58 mm) with its type sized so columnsFor(paperWidthMm) characters fill it — one column is 1.5 mm, the printer's own Font A cell — and a size with N columns is set at (body columns / N) em — small 0.75em, large 1.5em, xlarge 2em on 80 mm. A line wraps at the same place on screen as on paper, and nothing reaches the margin the head cannot print. Text is pure black, stroked about one dot thicker so a regular-weight stem prints solid rather than as dithered grey; a picture is drawn at one image pixel per dot (width / 8 mm), unsmoothed.

LINE_SIZE_METRICS and FONT_CELL_DOTS export the table, for a host that wants to draw its own preview.

Bands and boxes

  • band prints reverse (GS B 1), padded to the full column count so the black runs from one edge of the roll to the other — a reverse-printed space is a black cell. In HTML it is background:#000;color:#fff with print-color-adjust:exact, without which a browser drops the background when printing and the band comes out white on white.
  • box is drawn with the box-drawing characters CP850 carries (┌─┐│└┘), since ESC/POS has no rectangle command; it prints on every printer and renders identically in the browser.
  • Both close the line spacing (ESC 3 n, n = the cell height) so a multi-line band is one block and the verticals of a box join; ESC 2 restores it after.
  • Both default to bold — they are there to be noticed; pass bold: false to opt out.

Hardware assumption to verify on paper: ESC 3 n is read as n dots, which holds for the 203-dpi class this package targets (Bematech MP4200, Epson TM-T20 and alike, where the line-spacing motion unit is one dot). A printer whose vertical motion unit is different (set by GS P, or a 180/300-dpi head) would space framed lines tighter or looser than the cell; check a band and a box on the actual model before relying on them.

Rows

row(label, amount, paperWidthMm, { size?, bold?, indent? }) puts the amount flush right on the first line and wraps the label beside it (under an optional hanging indent). An amount so long the label would get under a third of the line moves to a line of its own below the label, still flush right. The amount is never split or truncated: one wider than the whole line prints whole on its own line and runs past the edge (the printer wraps it), because a cut amount is a wrong amount.

Widths are code points of NFC text (textWidth), not UTF-16 units, in every builder: an emoji counts one column and a decomposed é is composed first. That is exactly what the ESC/POS encoder writes — one CP850 byte per code point, ? for an unmappable one — so alignment holds on paper. A browser may draw an emoji wider than one monospace cell in the HTML preview.

Pictures

A RasterImage is { width, height, data } in printer dots (8 per mm): rows top to bottom, Math.ceil(width / 8) bytes each, MSB leftmost, 1 = black — exactly GS v 0's format, so the encoder copies it, in strips of at most 255 rows. Rasterise at printableDotsFor(paperWidthMm) for a full-width logo (576 dots on 80 mm, 384 on 58 mm) or smaller for a mark; neither encoder rescales. In HTML the same bits become a 1-bit BMP data: URI (no canvas, no deflate, the same code in Node and in a browser), sized at 12 dots per character so it takes the width on screen it takes on paper, with image-rendering: pixelated.

Getting a picture down to one bit (./raster)

A thermal head has one ink. A 50 % threshold is the wrong default: brand colours are mostly light — an orange like #ED7A1F has a luma of 0.57 — so a logo in one prints only its darkest details, or nothing.

import { rasterizeSvg, toMonochrome } from '@12-apps/thermal-printing/raster';
import { image, printableDotsFor } from '@12-apps/thermal-printing';

// A vector mark, drawn at the size it will print (12 mm = 96 dots):
const mark = toMonochrome(rasterizeSvg(markSvg, { width: 96 }));
// A bitmap logo the host decoded to RGBA at the dot width (canvas, sharp, …):
const logo = toMonochrome({ width, height, data: rgba });

lines.unshift(image(logo));

The policy (mode: 'auto', the default):

  • Flat artwork — a few solid colours, which is what a logo or a mark is — is thresholded at luma 0.75. What an owner sees: an orange, red, blue or green logo prints as a crisp black silhouette with its white details knocked out; a pale tint (light yellow, pastel, light grey — luma 0.75 and up) disappears. Show them the preview (rasterToDataUri) before paper does.
  • A photograph — many colours — is dithered (Atkinson; Floyd–Steinberg on request), so tone becomes dot density. An orange area comes out as a fine pattern of about 40 % black, not solid black.
  • A transparent pixel is paper. The result's method says which branch ran.

mode: 'threshold' | 'dither', threshold and kernel override the choice.

Why the SVG rasteriser is a small one. The full-fidelity options are native modules (resvg-js, sharp: a binary per platform and nothing in a browser) or a WebAssembly build (megabytes, and an async init) — in a package that has no dependencies at all. What a one-colour printer reproduces is flat fills, and a mark drawn for one plate is flat fills by construction, so rasterizeSvg draws exactly that, anti-aliased: path (every command), rect (rounded), circle, ellipse, polygon, polyline, g, transform, fill, fill-rule, fill-opacity, opacity, viewBox and preserveAspectRatio (meet or none). Whatever it does not draw — strokes, gradients, text, <image>, <use>, clipping, masks, filters, <style> — it names in unsupported, so a host can refuse the file or rasterise it itself and come in at toMonochrome. Decoding PNG/JPEG is deliberately left to the host, which already has a codec.

An uploaded SVG is untrusted input, so every cost it can ask for is capped, and a document over any cap is refused — a 0 × 0 result with the reason in unsupported — never thrown on and never allocated:

limitvaluereason in unsupported
source length, MAX_SVG_LENGTH256 000 characterstoo large
output, MAX_RASTER_WIDTH × MAX_RASTER_HEIGHT576 × 2048 dots (checked before allocating, including a height derived from the viewBox)output too large
shapes, MAX_SHAPES4096too many shapes
flattened vertices, MAX_PATH_POINTS250 000too many path points
scan-conversion work, MAX_FILL_WORK50 000 000 units, estimated from the edges before any row is filled (edge sub-rows × log₂ edges, plus 5 per bounding-box pixel — an upper bound on the real work)too complex to fill

The document is read by a hand-written linear scanner (no backtracking regular expressions, so no crafted comment, attribute or number can make parsing polynomial), and each shape is composited only across the pixels it covers on each row. toMonochrome checks its input too: a buffer that is not exactly width × height × 4 bytes, or dimensions past 576 × 2048, throw a RangeError before anything is allocated — those dimensions are the host's own choice after decoding, so a mismatch is a caller bug rather than something an upload reaches.

Finding a printer nobody wrote the address of down

A settings screen asking a shop owner for an IP address is asking a fair question of a network engineer and an unfair one of somebody selling lunch: the address was handed out by a router they have never logged into, to a device with no screen. ./discovery sweeps the local network for devices listening on the printing port and grades what it finds.

An open port proves nothing about the device. A print spooler, a second server or an unrelated appliance may all answer on 9100. So the probe asks a real ESC/POS question once connected — DLE EOT 1, real-time status, which prints nothing and is safe to send to a non-printer — and grades a device that answers confirmed against one that merely opened the port, candidate. Silence is never taken as evidence AGAINST a printer, because a good deal of this hardware does not implement status at all. Nothing picks for the operator: every candidate is returned, and the test page is still the only proof.

A browser cannot do any of this, which is what ./discovery/bridge is for. An HTTPS page may not fetch http://192.168.0.50 — mixed content — and even over plaintext Chrome's Private Network Access rules demand a preflight that a printer on :9100 could never answer. Loopback is the one exemption: 127.0.0.1 is potentially trustworthy, so a helper running on the merchant's own machine can hold the scan and hand the answer to the tab.

That server is reachable by every page the merchant has open, so it binds loopback only, answers only the origins it was started with, and exits on a TTL. The origin allowlist is the real control — browsers set Origin themselves and a page cannot forge it — and the TTL bounds the window it has to be wrong in. Without both, this is a port scanner any website could aim at a shop's network. runFinder therefore refuses to start with no origin: there is no safe default, so there is no default.

Packaging the helper

./discovery/cli is a function, not a binary, because this package publishes TypeScript source and a host's distribution is its own. A branded build bundles main() with esbuild and wraps it for each platform, baking its own origin in through PRINTER_FINDER_ORIGINS so a merchant double-clicks an icon rather than typing a flag:

esbuild --bundle --platform=node --format=cjs --outfile=finder.cjs \
  --banner:js='process.env.PRINTER_FINDER_ORIGINS ||= "https://admin.example.com"' \
  your-entry.cjs

A binary needs signing to be usable by the people it is for. An unsigned .exe meets Windows SmartScreen's "unrecognized app" wall and an unnotarized .app is refused outright by Gatekeeper — for a shop owner, either is where this feature ends. That is a certificate and a release pipeline, not code.

The three things that are easy to get wrong

The code page. A printer does not speak UTF-8 — it prints the glyph at each byte. Sending an accented word as UTF-8 puts two characters where one belongs, on every printer, looking exactly like a font problem. ./escpos selects CP850 and maps the Latin-script characters a ticket carries; anything outside the table loses its diacritic before it falls back to ?, because a word somebody can still read beats a ? mid-word.

"Sent" is not "printed". There is no protocol above a raw :9100 socket: no handshake, no acknowledgement, no status frame. An empty roll, a jam, and a perfect ticket are the same success — and window.print() returns when the dialog is dismissed, which is no better. A host that reports "printed" is making a promise the transport never made. Give the operator a reprint instead.

A destination that owns a printer is answered by it or by nothing. Switching one room's printer off must stop that room's tickets, not quietly reroute them somewhere else — a ticket coming out in the wrong room is one nobody in that room ever sees. ./routing keeps inactive printers in the map precisely so that "no printer here" and "the printer here is off" stay different answers.

Adoption

See ADOPTING.md.

版本与发布记录

@12-apps/thermal-printing 共发布 8 个版本,首次发布于 2026/08/27;过去 12 个月发布了 8 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
1.4.0latest2026/10/1919 天前
1.3.32026/09/291 个月前
1.3.22026/09/291 个月前
1.3.12026/09/271 个月前
1.3.02026/09/271 个月前
1.2.02026/09/261 个月前

关于 @12-apps/thermal-printing 的常见问题

@12-apps/thermal-printing 是什么?

Receipt printing for the two ways a thermal printer is attached, behind ONE fixed-width document. Isomorphic line model and wrapping (./), four type sizes, inverted bands, boxes, label/amount rows and raster images, the ESC/POS encoder with its CP850 code

如何安装 @12-apps/thermal-printing?

在项目目录运行 npm install @12-apps/thermal-printing,或使用 pnpm add / yarn add / bun add。

@12-apps/thermal-printing 支持 TypeScript 吗?

该包的 package.json 没有声明类型入口。需要类型提示时,可以在 npm 上查找社区维护的 @types/12-apps__thermal-printing 是否存在。

@12-apps/thermal-printing 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@12-apps/thermal-printing 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@12-apps/thermal-printing 可以商用吗?

@12-apps/thermal-printing 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@12-apps/thermal-printing 的下载量有多少?

根据 npm 官方下载统计,@12-apps/thermal-printing 在 2026/10/05 – 2026/11/03 这 30 天内被下载 32,912 次,日均约 1,097 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@12-apps/thermal-printing 还在维护吗?

最新版本 1.4.0 发布于 2026/10/19(19 天前);至今共发布 8 个版本,其中过去 12 个月发布了 8 个;首次发布于 2026/08/27。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。