@portable/quoted-printable
Quoted-printable encoding and decoding (RFC 2045 section 6.7), as functions and as streams. Runs in browsers, Cloudflare Workers, Deno, Bun and Node 24+.
Replaces libqp and quoted-printable.
npm install @portable/quoted-printableimport * as QuotedPrintable from '@portable/quoted-printable';
QuotedPrintable.encode('tere jõgeva'); // 'tere j=C3=B5geva'
new TextDecoder().decode(QuotedPrintable.decode('tere j=C3=B5geva')); // 'tere jõgeva'
const body = file
.stream()
.pipeThrough(new QuotedPrintable.EncoderStream());
const bytes = response.body!.pipeThrough(
new QuotedPrintable.DecoderStream(),
);API
| Export | Takes | Returns |
|---|---|---|
encode(input, options?) | Uint8Array or string (encoded as UTF-8) | string |
decode(input) | string (read as UTF-8) or Uint8Array | Uint8Array |
new EncoderStream(options?) | Uint8Array or string chunks | a TransformStream that emits the encoded text as ASCII bytes |
new DecoderStream(options?) | string or Uint8Array chunks | a TransformStream that emits the decoded bytes |
encode() keeps line breaks, escapes =, bytes outside printable ASCII, and a space or tab before a line break or at the end. It also escapes a hyphen that would start a line, so no line of the output can read as a MIME boundary. It inserts soft line breaks (= and CRLF) to keep each line within lineLength, preferring to break after a space or punctuation, and never breaks inside an escape or a UTF-8 sequence.
decode() removes whitespace at the end of each line and soft line breaks (= before CRLF, LF or the end), and decodes =XX escapes in either case. Anything else, including a malformed escape or a raw 8-bit byte, is copied as is. It never throws. To get text in another charset, pass the bytes to new TextDecoder(charset).
Both streams produce the same bytes as the functions however the input is chunked, including when a chunk boundary splits an escape, a CRLF, a whitespace run or a surrogate pair.
Options
| Option | Default | Description |
|---|---|---|
lineLength | 76 | Longest line, not counting the line break. An integer of at least 4, or false for no soft line breaks. Anything else throws a RangeError. |
binary | false | Escape a CR or LF that isn't part of a CRLF pair, so a transport that rewrites line endings can't change the data. |
signal | none | EncoderStream and DecoderStream only. Aborting errors the stream with signal.reason and cancels the source piped into it. |
DecoderStream takes no other option.
Why use this over libqp and quoted-printable
Output matches libqp 3.0.1 byte for byte, with web streams instead of Node streams, and the package depends only on other @portable/* packages. The bugs below are fixed in libqp 3, but libqp 2.x and quoted-printable still have them.
- Encoding 512 KB of text was about 1.2× faster than libqp 3.0.1 and 3× faster than quoted-printable. Decoding 256 KB of random bytes was about 3× faster than libqp.
encodeescapes a hyphen that would start a line, so no line of the body can read as a MIME boundary. libqp before 2.1.3 could start a line with--.EncoderStreamoutput doesn't depend on how the input is chunked, and a soft line break never splits a UTF-8 sequence. libqp before 3.0.0 got both wrong, and quoted-printable still splits UTF-8 sequences.decoderemoves the whole run of whitespace at the end of a line, in linear time. quoted-printable removes one character (quoted-printable #8), and libqp before 2.1.3 took quadratic time.encodetakes aUint8Arrayand text above U+00FF. quoted-printable takes neither: it throws aRangeErroron that text (quoted-printable #4).encodebreaks lines at 76 characters by default, and libqp'sencodedoesn't break lines at all. PasslineLength: falseto match libqp'sencode.- libqp's
wrap, which adds soft line breaks to quoted-printable you already have, has no equivalent. SetlineLengthwhen you encode. lineLengthmust befalseor an integer of at least 4. libqp treats 0,NaNand non-numbers as 76 and raises anything below 4 to 4.decodereads a string as UTF-8, asencodedoes, and returns aUint8Arrayinstead of libqp'sBuffer. libqp reads each character's low byte, so pass aUint8Arrayto decode raw 8-bit data.- libqp's
EncodercountsinputBytesandoutputBytes, andEncoderStreamdoesn't. Count chunks in aTransformStreamof your own. - quoted-printable escapes every CR and LF and keeps every line within 75 characters.
encodekeeps line breaks, escapes a lone CR or LF only withbinary: true, and allows 76. - quoted-printable's
decodealso treats=before a lone CR as a soft line break. RFC 2045 and libqp don't, and neither doesdecode. - The libqp 2.x
Encoderkept a hyphen after a lone CR literal, which starts a line once a relay turns the CR into CRLF.encodefollows libqp 3, which escapes it.
| @portable/quoted-printable | libqp | quoted-printable | |
|---|---|---|---|
| Encode 512 KB of text | 106 | 90.6 | 38.2 |
| Encode 256 KB of random bytes | 155 | 132 | – |
| Encode a 512 KB text stream in 16 KB chunks | 97.0 | 94.2 | – |
| Decode the encoded 512 KB of text | 97.8 | 86.3 | 34.3 |
| Decode the encoded 256 KB of random bytes | 255 | 76.3 | 19.0 |
| Decode the encoded text as a stream | 105 | 78.1 | – |
Operations per second from pnpm bench, higher is better, in Node 24.22.0, on Intel(R) Xeon(R) Processor @ 2.10GHz (4 cores). A dash means the library has no equivalent.
| @portable/quoted-printable | libqp | quoted-printable | |
|---|---|---|---|
| Minified and gzipped | 2.3 kB | 1.8 kB | 1.1 kB |
| Runtime dependencies | 3 | 0 | 1 |
| Node built-ins used | none | Buffer, stream | none |
| TypeScript types | included | included | @types/quoted-printable |
| Version compared | – | 3.0.1 | 1.0.1 |
Sizes cover the imports in the usage example, or the equivalent imports of each library, bundled for the browser by scripts/bundle-size.ts. Runtime dependencies count every package installed with the published package. A library that uses Node built-ins needs polyfills in a browser and the nodejs_compat flag in Cloudflare Workers.