mhtml-stream 2.0.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/dist/headers.d.ts +1 -1
- package/dist/headers.js +21 -17
- package/dist/index.d.ts +22 -1
- package/dist/index.js +15 -9
- package/dist/index.spec.js +80 -0
- package/dist/mhtml.esm.min.js +1 -1
- package/dist/utils.d.ts +20 -3
- package/dist/utils.js +55 -14
- package/dist/utils.spec.js +33 -5
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -5,10 +5,10 @@ MHTML Stream
|
|
|
5
5
|
[](https://www.npmjs.com/package/mhtml-stream)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
Library for parsing MHTML data as streams using modern WHATWG streams and async
|
|
9
|
+
iterators. Because it relies on modern cross javascript standards it works
|
|
10
|
+
out-of-the-box in all javascript environments, with only a little tweaking
|
|
11
|
+
necessary for module definitions.
|
|
12
12
|
|
|
13
13
|
Usage
|
|
14
14
|
-----
|
|
@@ -39,3 +39,7 @@ Notes
|
|
|
39
39
|
comes to whether whitespace should be added when unfolding. This currently
|
|
40
40
|
uses the first whitespace character to indicate folding, and preservers any
|
|
41
41
|
others.
|
|
42
|
+
- Decoded part content preserves the original MIME line endings. The parser
|
|
43
|
+
splits the stream on `\r\n`, and the quoted-printable and 7bit/8bit decoders
|
|
44
|
+
re-insert that `\r\n` between lines, so extraction is lossless. Both decoders
|
|
45
|
+
accept a `newLine` argument if you'd rather normalize to `\n`.
|
package/dist/headers.d.ts
CHANGED
|
@@ -56,7 +56,7 @@ export declare class Headers implements MhtmlHeaders {
|
|
|
56
56
|
get(key: string, delim?: string): string | null;
|
|
57
57
|
getAll(key: string): string[];
|
|
58
58
|
has(key: string): boolean;
|
|
59
|
-
keys():
|
|
59
|
+
keys(): IterableIterator<string>;
|
|
60
60
|
values(delim?: string): IterableIterator<string>;
|
|
61
61
|
valuesAll(): IterableIterator<string>;
|
|
62
62
|
}
|
package/dist/headers.js
CHANGED
|
@@ -1,55 +1,59 @@
|
|
|
1
1
|
export class Headers {
|
|
2
|
+
// keyed by lowercased name; the original name casing (first seen) is
|
|
3
|
+
// preserved for iteration while lookups are case-insensitive, per RFC 5322
|
|
2
4
|
#raw = new Map();
|
|
3
5
|
[Symbol.iterator]() {
|
|
4
6
|
return this.entries();
|
|
5
7
|
}
|
|
6
8
|
append(key, value) {
|
|
7
|
-
const
|
|
8
|
-
if (
|
|
9
|
-
this.#raw.set(key, [value]);
|
|
9
|
+
const entry = this.#raw.get(key.toLowerCase());
|
|
10
|
+
if (entry === undefined) {
|
|
11
|
+
this.#raw.set(key.toLowerCase(), { key, values: [value] });
|
|
10
12
|
}
|
|
11
13
|
else {
|
|
12
|
-
|
|
14
|
+
entry.values.push(value);
|
|
13
15
|
}
|
|
14
16
|
}
|
|
15
17
|
*entries(delim = ", ") {
|
|
16
|
-
for (const
|
|
18
|
+
for (const { key, values } of this.#raw.values()) {
|
|
17
19
|
yield [key, values.join(delim)];
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
*entriesAll() {
|
|
21
|
-
for (const
|
|
23
|
+
for (const { key, values } of this.#raw.values()) {
|
|
22
24
|
for (const val of values) {
|
|
23
25
|
yield [key, val];
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
get(key, delim = ", ") {
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
+
const entry = this.#raw.get(key.toLowerCase());
|
|
31
|
+
if (entry === undefined) {
|
|
30
32
|
return null;
|
|
31
33
|
}
|
|
32
34
|
else {
|
|
33
|
-
return
|
|
35
|
+
return entry.values.join(delim);
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
getAll(key) {
|
|
37
|
-
return this.#raw.get(key) ?? [];
|
|
39
|
+
return this.#raw.get(key.toLowerCase())?.values ?? [];
|
|
38
40
|
}
|
|
39
41
|
has(key) {
|
|
40
|
-
return this.#raw.has(key);
|
|
42
|
+
return this.#raw.has(key.toLowerCase());
|
|
41
43
|
}
|
|
42
|
-
keys() {
|
|
43
|
-
|
|
44
|
+
*keys() {
|
|
45
|
+
for (const { key } of this.#raw.values()) {
|
|
46
|
+
yield key;
|
|
47
|
+
}
|
|
44
48
|
}
|
|
45
49
|
*values(delim = ", ") {
|
|
46
|
-
for (const
|
|
47
|
-
yield
|
|
50
|
+
for (const { values } of this.#raw.values()) {
|
|
51
|
+
yield values.join(delim);
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
*valuesAll() {
|
|
51
|
-
for (const
|
|
52
|
-
yield*
|
|
55
|
+
for (const { values } of this.#raw.values()) {
|
|
56
|
+
yield* values;
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,28 @@ export interface MhtmlFile {
|
|
|
15
15
|
export type Decoder = (lines: AsyncIterable<Uint8Array>) => AsyncIterable<Uint8Array>;
|
|
16
16
|
/** options for mhtml parsing */
|
|
17
17
|
export interface ParseOptions {
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* custom decoders keyed by (lowercased) Content-Transfer-Encoding
|
|
20
|
+
*
|
|
21
|
+
* Use these to handle an encoding the defaults don't, or to override one. A
|
|
22
|
+
* {@link Decoder} turns the CRLF-split lines of a part into its decoded
|
|
23
|
+
* bytes; the parser strips the CRLFs when splitting, so a passthrough decoder
|
|
24
|
+
* re-emits them between lines:
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* const crlf = new Uint8Array([13, 10]);
|
|
28
|
+
* const decoderOverrides = new Map([
|
|
29
|
+
* ["binary", async function* (lines) {
|
|
30
|
+
* let first = true;
|
|
31
|
+
* for await (const line of lines) {
|
|
32
|
+
* if (!first) yield crlf;
|
|
33
|
+
* first = false;
|
|
34
|
+
* yield line;
|
|
35
|
+
* }
|
|
36
|
+
* }],
|
|
37
|
+
* ]);
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
19
40
|
decoderOverrides?: Map<string, Decoder>;
|
|
20
41
|
}
|
|
21
42
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { toByteArray } from "base64-js";
|
|
2
2
|
import { Headers } from "./headers";
|
|
3
|
-
import { bytesEqual, collect, decodeBase64, decodeBinary, decodeIdentity, decodeQuotedPrintable, splitStream, } from "./utils";
|
|
3
|
+
import { bytesEqual, collect, decodeBase64, decodeBinary, decodeIdentity, decodeQuotedPrintable, isHexDigit, splitStream, } from "./utils";
|
|
4
4
|
// NOTE we use this for ascii because it's faster than manual, but may cause
|
|
5
5
|
// unexpected errors if the assumption is violated
|
|
6
6
|
const encoder = new TextEncoder();
|
|
@@ -21,9 +21,12 @@ function decodeQEncoding(text) {
|
|
|
21
21
|
}
|
|
22
22
|
else if (code === 61) {
|
|
23
23
|
// encoded character
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
const high = text.charCodeAt(++ind);
|
|
25
|
+
const low = text.charCodeAt(++ind);
|
|
26
|
+
if (!isHexDigit(high) || !isHexDigit(low)) {
|
|
27
|
+
throw new Error(`got invalid hex escape when decoding q-quoted word: "${text}"`);
|
|
28
|
+
}
|
|
29
|
+
val = parseInt(String.fromCharCode(high, low), 16);
|
|
27
30
|
}
|
|
28
31
|
else {
|
|
29
32
|
// residual ascii
|
|
@@ -81,12 +84,12 @@ async function parseHeaders(iter) {
|
|
|
81
84
|
headers.append(key, val);
|
|
82
85
|
}
|
|
83
86
|
if (line) {
|
|
84
|
-
const delim = line.indexOf(":
|
|
87
|
+
const delim = line.indexOf(":");
|
|
85
88
|
if (delim === -1) {
|
|
86
89
|
throw new Error(`header line didn't have key-value delimiter: "${line}"`);
|
|
87
90
|
}
|
|
88
91
|
key = line.slice(0, delim);
|
|
89
|
-
val = decodeLine(line.slice(delim +
|
|
92
|
+
val = decodeLine(line.slice(delim + 1).replace(/^\s+/, ""));
|
|
90
93
|
}
|
|
91
94
|
else {
|
|
92
95
|
return headers;
|
|
@@ -116,10 +119,13 @@ function getBoundary(headers) {
|
|
|
116
119
|
let bound;
|
|
117
120
|
let multipart = false;
|
|
118
121
|
for (const field of contentType.split(/;\s*/)) {
|
|
119
|
-
|
|
122
|
+
// the media type and the boundary parameter name are case-insensitive, but
|
|
123
|
+
// the boundary value itself is not, so match on a lowercased copy
|
|
124
|
+
const lower = field.toLowerCase();
|
|
125
|
+
if (lower.startsWith("multipart/")) {
|
|
120
126
|
multipart = true;
|
|
121
127
|
}
|
|
122
|
-
else if (
|
|
128
|
+
else if (lower.startsWith("boundary=")) {
|
|
123
129
|
bound = field.slice(9);
|
|
124
130
|
// TODO handling of quoted fields is not great
|
|
125
131
|
if (bound.startsWith('"') && bound.endsWith('"')) {
|
|
@@ -155,7 +161,7 @@ export async function* parseMhtml(stream, { decoderOverrides = new Map() } = {})
|
|
|
155
161
|
// parse out headers and get encoding for content
|
|
156
162
|
const headers = await parseHeaders(lines);
|
|
157
163
|
const [boundary, terminus] = bound ?? (bound = getBoundary(headers));
|
|
158
|
-
const encoding = headers.get("Content-Transfer-Encoding") ?? "7bit";
|
|
164
|
+
const encoding = (headers.get("Content-Transfer-Encoding") ?? "7bit").toLowerCase();
|
|
159
165
|
const decode = decoders.get(encoding);
|
|
160
166
|
if (decode === undefined) {
|
|
161
167
|
throw new Error(`unhandled encoding type: ${encoding}`);
|
package/dist/index.spec.js
CHANGED
|
@@ -140,6 +140,86 @@ Subject: =?iso-8859-1?Q?=A1Hola,\xffse=F1or!?=
|
|
|
140
140
|
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
141
141
|
await expect(consume(parser)).rejects.toThrow("got non-ascii character when decoding q-quoted word");
|
|
142
142
|
});
|
|
143
|
+
test("decodes base64 wrapped at a non-multiple-of-4 column", async () => {
|
|
144
|
+
// "hello world hello world" wrapped at column 23 (not a multiple of 4)
|
|
145
|
+
const content = `MIME-Version: 1.0
|
|
146
|
+
Content-Type: multipart/mixed; boundary=frontier
|
|
147
|
+
|
|
148
|
+
--frontier
|
|
149
|
+
Content-Type: application/octet-stream
|
|
150
|
+
Content-Transfer-Encoding: base64
|
|
151
|
+
|
|
152
|
+
aGVsbG8gd29ybGQgaGVsbG8
|
|
153
|
+
gd29ybGQ=
|
|
154
|
+
--frontier--
|
|
155
|
+
`;
|
|
156
|
+
const files = [];
|
|
157
|
+
for await (const file of parseMhtml(stringToStream(content))) {
|
|
158
|
+
files.push(file);
|
|
159
|
+
}
|
|
160
|
+
expect(decoder.decode(files[1].content)).toStrictEqual("hello world hello world");
|
|
161
|
+
});
|
|
162
|
+
test("matches header names and encodings case-insensitively", async () => {
|
|
163
|
+
const content = `mime-version: 1.0
|
|
164
|
+
content-type: MULTIPART/mixed; BOUNDARY=frontier
|
|
165
|
+
|
|
166
|
+
--frontier
|
|
167
|
+
content-type: application/octet-stream
|
|
168
|
+
content-transfer-encoding: Base64
|
|
169
|
+
|
|
170
|
+
aGVsbG8gd29ybGQ=
|
|
171
|
+
--frontier--
|
|
172
|
+
`;
|
|
173
|
+
const files = [];
|
|
174
|
+
for await (const file of parseMhtml(stringToStream(content))) {
|
|
175
|
+
files.push(file);
|
|
176
|
+
}
|
|
177
|
+
const headers = files.map(({ headers }) => Object.fromEntries(headers));
|
|
178
|
+
expect(headers).toEqual([
|
|
179
|
+
{
|
|
180
|
+
"mime-version": "1.0",
|
|
181
|
+
"content-type": "MULTIPART/mixed; BOUNDARY=frontier",
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
"content-type": "application/octet-stream",
|
|
185
|
+
"content-transfer-encoding": "Base64",
|
|
186
|
+
},
|
|
187
|
+
]);
|
|
188
|
+
expect(decoder.decode(files[1].content)).toStrictEqual("hello world");
|
|
189
|
+
});
|
|
190
|
+
test("accepts headers without a space after the colon", async () => {
|
|
191
|
+
const content = `MIME-Version:1.0
|
|
192
|
+
Content-Type:multipart/mixed; boundary=frontier
|
|
193
|
+
X-Empty:
|
|
194
|
+
|
|
195
|
+
This is a message with multiple parts in MIME format.
|
|
196
|
+
--frontier--
|
|
197
|
+
`;
|
|
198
|
+
const files = [];
|
|
199
|
+
for await (const file of parseMhtml(stringToStream(content))) {
|
|
200
|
+
files.push(file);
|
|
201
|
+
}
|
|
202
|
+
const headers = files.map(({ headers }) => Object.fromEntries(headers));
|
|
203
|
+
expect(headers).toEqual([
|
|
204
|
+
{
|
|
205
|
+
"MIME-Version": "1.0",
|
|
206
|
+
"Content-Type": "multipart/mixed; boundary=frontier",
|
|
207
|
+
"X-Empty": "",
|
|
208
|
+
},
|
|
209
|
+
]);
|
|
210
|
+
const text = files.map(({ content }) => decoder.decode(content));
|
|
211
|
+
expect(text).toEqual([
|
|
212
|
+
"This is a message with multiple parts in MIME format.",
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
test("fails on invalid hex in q-encoding", async () => {
|
|
216
|
+
const content = `MIME-Version: 1.0
|
|
217
|
+
Subject: =?utf-8?Q?a=ZZb?=
|
|
218
|
+
`;
|
|
219
|
+
const parser = parseMhtml(stringToStream(content));
|
|
220
|
+
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
221
|
+
await expect(consume(parser)).rejects.toThrow("got invalid hex escape when decoding q-quoted word");
|
|
222
|
+
});
|
|
143
223
|
test("fails without empty header delimiter", async () => {
|
|
144
224
|
const content = `MIME-Version: 1.0`;
|
|
145
225
|
const parser = parseMhtml(stringToStream(content));
|
package/dist/mhtml.esm.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var R=k;var m=[],Z=[],f=typeof Uint8Array<"u"?Uint8Array:Array,j="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";for($=0,q=j.length;$<q;++$)m[$]=j[$],Z[j.charCodeAt($)]=$;var $,q;Z[45]=62;Z[95]=63;function L(G){var J=G.length;if(J%4>0)throw Error("Invalid string. Length must be a multiple of 4");var K=G.indexOf("=");if(K===-1)K=J;var M=K===J?0:4-K%4;return[K,M]}function h(G,J,K){return(J+K)*3/4-K}function k(G){var J,K=L(G),M=K[0],U=K[1],W=new f(h(G,M,U)),X=0,V=U>0?M-4:M,Y;for(Y=0;Y<V;Y+=4)J=Z[G.charCodeAt(Y)]<<18|Z[G.charCodeAt(Y+1)]<<12|Z[G.charCodeAt(Y+2)]<<6|Z[G.charCodeAt(Y+3)],W[X++]=J>>16&255,W[X++]=J>>8&255,W[X++]=J&255;if(U===2)J=Z[G.charCodeAt(Y)]<<2|Z[G.charCodeAt(Y+1)]>>4,W[X++]=J&255;if(U===1)J=Z[G.charCodeAt(Y)]<<10|Z[G.charCodeAt(Y+1)]<<4|Z[G.charCodeAt(Y+2)]>>2,W[X++]=J>>8&255,W[X++]=J&255;return W}class Q{#G=new Map;[Symbol.iterator](){return this.entries()}append(G,J){let K=this.#G.get(G.toLowerCase());if(K===void 0)this.#G.set(G.toLowerCase(),{key:G,values:[J]});else K.values.push(J)}*entries(G=", "){for(let{key:J,values:K}of this.#G.values())yield[J,K.join(G)]}*entriesAll(){for(let{key:G,values:J}of this.#G.values())for(let K of J)yield[G,K]}get(G,J=", "){let K=this.#G.get(G.toLowerCase());if(K===void 0)return null;else return K.values.join(J)}getAll(G){return this.#G.get(G.toLowerCase())?.values??[]}has(G){return this.#G.has(G.toLowerCase())}*keys(){for(let{key:G}of this.#G.values())yield G}*values(G=", "){for(let{values:J}of this.#G.values())yield J.join(G)}*valuesAll(){for(let{values:G}of this.#G.values())yield*G}}function E(G,J){if(G.length!==J.length)return!1;else if(G.byteOffset%4===J.byteOffset%4){let K=(4-G.byteOffset%4)%4,M=Math.floor((G.byteLength-K)/4),U=K+M*4,W=new Uint32Array(G.buffer,G.byteOffset+K,M),X=new Uint32Array(J.buffer,J.byteOffset+K,M);for(let V=0;V<K;++V)if(G[V]!==J[V])return!1;for(let V=0;V<M;++V)if(W[V]!==X[V])return!1;for(let V=U;V<G.length;++V)if(G[V]!==J[V])return!1;return!0}else{for(let K of G.keys())if(G[K]!==J[K])return!1;return!0}}function b(G,J){return G.findIndex((K,M)=>{if(K!==J[0]||M+J.length>G.length)return!1;else{for(let U=1;U<J.length;++U)if(G[M+U]!==J[U])return!1;return!0}})}async function*O(G,J){let K=new Uint8Array(0);for await(let M of G){K=K.length?T([K,M]):M;let U;while((U=b(K,J))!==-1)yield K.subarray(0,U),K=K.subarray(U+J.length)}yield K}function T(G){let J=G.reduce((U,W)=>U+W.length,0),K=new Uint8Array(J),M=0;for(let U of G)K.set(U,M),M+=U.length;return K}async function C(G){let J=[];for await(let K of G)J.push(K);return T(J)}var D=new Uint8Array([13,10]);function S(G){return G>=48&&G<=57||G>=65&&G<=70||G>=97&&G<=102}async function*A(G,J=D){let K=!1;for await(let M of G){let U=new Uint8Array(M.length+J.length),W=0;if(K)U.set(J,W),W+=J.length,K=!1;let X=!1;for(let V=0;V<M.length;++V){let Y=M[V];if(Y>=128)throw Error(`got non-ascii character when decoding quoted printable: ${Y}`);if(Y!==61)U[W++]=Y;else{let z=M[++V];if(z===void 0)X=!0;else{let P=M[++V];if(P===void 0)throw Error("quoted printable escape (=) was not followed by two bytes");else if(!S(z)||!S(P))throw Error(`quoted printable escape (=) was not followed by two hex digits: "=${String.fromCharCode(z,P)}"`);U[W++]=parseInt(String.fromCharCode(z,P),16)}}}if(!X)K=!0;yield U.subarray(0,W)}}var y=new TextDecoder;async function*I(G){let J="";for await(let K of G){J+=y.decode(K).replace(/\s/g,"");let M=J.length-J.length%4;if(M>0)yield R(J.slice(0,M)),J=J.slice(M)}if(J.length>0)yield R(J)}async function*F(G,J=D){let K=!0;for await(let M of G){if(K)K=!1;else yield J;yield M}}function H(){throw Error("binary transfer-encoding is explicitly not supported and trying to add an implementation will likely result in unexpected results, but if you want to handle it anyway, override `binary` in `decoderOverrides`")}var B=new TextEncoder,x=new TextDecoder;function p(G){let J=new Uint8Array(G.length),K=0;for(let M=0;M<G.length;++M){let U=G.charCodeAt(M),W;if(U>=128)throw Error(`got non-ascii character when decoding q-quoted word: "${G}"`);else if(U===95)W=32;else if(U===61){let X=G.charCodeAt(++M),V=G.charCodeAt(++M);if(!S(X)||!S(V))throw Error(`got invalid hex escape when decoding q-quoted word: "${G}"`);W=parseInt(String.fromCharCode(X,V),16)}else W=U;J[K++]=W}return J.subarray(0,K)}var c=/^=\?([^?\s]+)\?([BQ])\?([^?\s]+)\?=$/;function w(G){let J=c.exec(G);if(J){let[,K,M,U]=J,W;if(M==="Q")W=p(U);else W=R(U);return new TextDecoder(K).decode(W)}else return G}async function u(G){let J=new Q,K="",M="";for(;;){let{done:U,value:W}=await G.next();if(U)throw Error("didn't find an empty line to signify the end of header parsing");let X=x.decode(W);if(/^\s/.test(X))M+=w(X.substring(1));else{if(K)J.append(K,M);if(X){let V=X.indexOf(":");if(V===-1)throw Error(`header line didn't have key-value delimiter: "${X}"`);K=X.slice(0,V),M=w(X.slice(V+1).replace(/^\s+/,""))}else return J}}}var a=new Map([["7bit",F],["base64",I],["quoted-printable",A],["8bit",F],["binary",H]]);function s(G){let J=G.get("Content-Type");if(J===null)throw Error(`first headers didn't contain a content type: ${JSON.stringify(Object.fromEntries(G))}`);let K,M=!1;for(let X of J.split(/;\s*/)){let V=X.toLowerCase();if(V.startsWith("multipart/"))M=!0;else if(V.startsWith("boundary=")){if(K=X.slice(9),K.startsWith('"')&&K.endsWith('"'))K=K.slice(1,-1)}}if(!M||K===void 0)throw Error("first content type header didn't contain 'multipart/...' and a boundary string");let U=B.encode(`--${K}`),W=B.encode(`--${K}--`);return[U,W]}async function*e(G,{decoderOverrides:J=new Map}={}){let K=new Map([...a.entries(),...J.entries()]),M=new Uint8Array([13,10]),U=O(G,M),W=null,X=!0;while(X){let V=await u(U),[Y,z]=W??(W=s(V)),P=(V.get("Content-Transfer-Encoding")??"7bit").toLowerCase(),N=K.get(P);if(N===void 0)throw Error(`unhandled encoding type: ${P}`);let g=await C(N({[Symbol.asyncIterator](){return{async next(){let{done:v,value:_}=await U.next();if(v)throw Error(`stream didn't end with the appropriate termination boundary: ${x.decode(z)}`);else if(E(_,Y))return{done:!0,value:void 0};else if(E(_,z))return X=!1,{done:!0,value:void 0};else return{value:_}}}}}));yield{headers:V,content:g}}}export{e as parseMhtml};
|
package/dist/utils.d.ts
CHANGED
|
@@ -18,21 +18,38 @@ export declare function splitStream(iter: AsyncIterable<Uint8Array>, split: Uint
|
|
|
18
18
|
* collect an async iterable of buffers into one
|
|
19
19
|
*/
|
|
20
20
|
export declare function collect(stream: AsyncIterable<Uint8Array>): Promise<Uint8Array>;
|
|
21
|
+
/** whether a character code is a hex digit (0-9, A-F, a-f) */
|
|
22
|
+
export declare function isHexDigit(code: number): boolean;
|
|
21
23
|
/**
|
|
22
24
|
* decoder for quoted printable
|
|
23
25
|
*
|
|
24
26
|
* If quoted printable "lines" aren't escaped with an "=" then a new line needs
|
|
25
|
-
* to be inserted. We use `newLine
|
|
27
|
+
* to be inserted. We use `newLine`, which defaults to CRLF to match the
|
|
28
|
+
* canonical MIME form; pass a custom separator (e.g. a single "\n") to
|
|
29
|
+
* normalize instead. The separator goes between lines; the CRLF before the MIME
|
|
30
|
+
* boundary belongs to the delimiter, not the body.
|
|
26
31
|
*/
|
|
27
32
|
export declare function decodeQuotedPrintable(lines: AsyncIterable<Uint8Array>, newLine?: Uint8Array): AsyncIterableIterator<Uint8Array>;
|
|
28
33
|
/**
|
|
29
34
|
* decoder for base64
|
|
35
|
+
*
|
|
36
|
+
* RFC 2045 requires decoders to ignore line breaks and decode the concatenated
|
|
37
|
+
* stream, so producers may wrap at any column. We strip whitespace and buffer
|
|
38
|
+
* characters that don't yet form a complete four-character quantum, flushing
|
|
39
|
+
* the remainder at the end of the part.
|
|
30
40
|
*/
|
|
31
41
|
export declare function decodeBase64(lines: AsyncIterable<Uint8Array>): AsyncIterableIterator<Uint8Array>;
|
|
32
42
|
/**
|
|
33
43
|
* decoder for 7bit and 8bit
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
*
|
|
45
|
+
* 7bit/8bit apply no transfer transformation, so the content bytes are the
|
|
46
|
+
* payload as-is. parseMhtml splits the stream on CRLF to find part boundaries,
|
|
47
|
+
* so we re-insert `newLine` (defaulting to CRLF) between lines to restore the
|
|
48
|
+
* original bytes exactly. Pass `newLine` (e.g. a single "\n") to normalize line
|
|
49
|
+
* endings instead. The separator goes between lines; the CRLF before the
|
|
50
|
+
* boundary belongs to the delimiter, not the body.
|
|
51
|
+
*/
|
|
52
|
+
export declare function decodeIdentity(lines: AsyncIterable<Uint8Array>, newLine?: Uint8Array): AsyncIterableIterator<Uint8Array>;
|
|
36
53
|
/**
|
|
37
54
|
* decoder for binary
|
|
38
55
|
*
|
package/dist/utils.js
CHANGED
|
@@ -111,19 +111,33 @@ export async function collect(stream) {
|
|
|
111
111
|
}
|
|
112
112
|
return concat(chunks);
|
|
113
113
|
}
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
const crlf = new Uint8Array([13, 10]);
|
|
115
|
+
/** whether a character code is a hex digit (0-9, A-F, a-f) */
|
|
116
|
+
export function isHexDigit(code) {
|
|
117
|
+
return ((code >= 48 && code <= 57) ||
|
|
118
|
+
(code >= 65 && code <= 70) ||
|
|
119
|
+
(code >= 97 && code <= 102));
|
|
120
|
+
}
|
|
116
121
|
/**
|
|
117
122
|
* decoder for quoted printable
|
|
118
123
|
*
|
|
119
124
|
* If quoted printable "lines" aren't escaped with an "=" then a new line needs
|
|
120
|
-
* to be inserted. We use `newLine
|
|
125
|
+
* to be inserted. We use `newLine`, which defaults to CRLF to match the
|
|
126
|
+
* canonical MIME form; pass a custom separator (e.g. a single "\n") to
|
|
127
|
+
* normalize instead. The separator goes between lines; the CRLF before the MIME
|
|
128
|
+
* boundary belongs to the delimiter, not the body.
|
|
121
129
|
*/
|
|
122
|
-
export async function* decodeQuotedPrintable(lines, newLine =
|
|
130
|
+
export async function* decodeQuotedPrintable(lines, newLine = crlf) {
|
|
131
|
+
let pendingNewLine = false; // a hard line break from the previous line
|
|
123
132
|
for await (const bytes of lines) {
|
|
124
133
|
const res = new Uint8Array(bytes.length + newLine.length);
|
|
125
|
-
let softLine = false; // if newline wasn't escaped so we need to add
|
|
126
134
|
let destInd = 0;
|
|
135
|
+
if (pendingNewLine) {
|
|
136
|
+
res.set(newLine, destInd);
|
|
137
|
+
destInd += newLine.length;
|
|
138
|
+
pendingNewLine = false;
|
|
139
|
+
}
|
|
140
|
+
let softLine = false; // if newline was escaped, so we shouldn't add one
|
|
127
141
|
for (let ind = 0; ind < bytes.length; ++ind) {
|
|
128
142
|
const code = bytes[ind];
|
|
129
143
|
if (code >= 128) {
|
|
@@ -144,16 +158,15 @@ export async function* decodeQuotedPrintable(lines, newLine = defaultNewLine) {
|
|
|
144
158
|
if (second === undefined) {
|
|
145
159
|
throw new Error("quoted printable escape (=) was not followed by two bytes");
|
|
146
160
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
res[destInd++] =
|
|
161
|
+
else if (!isHexDigit(first) || !isHexDigit(second)) {
|
|
162
|
+
throw new Error(`quoted printable escape (=) was not followed by two hex digits: "=${String.fromCharCode(first, second)}"`);
|
|
163
|
+
}
|
|
164
|
+
res[destInd++] = parseInt(String.fromCharCode(first, second), 16);
|
|
151
165
|
}
|
|
152
166
|
}
|
|
153
167
|
}
|
|
154
168
|
if (!softLine) {
|
|
155
|
-
|
|
156
|
-
destInd += newLine.length;
|
|
169
|
+
pendingNewLine = true;
|
|
157
170
|
}
|
|
158
171
|
yield res.subarray(0, destInd);
|
|
159
172
|
}
|
|
@@ -161,17 +174,45 @@ export async function* decodeQuotedPrintable(lines, newLine = defaultNewLine) {
|
|
|
161
174
|
const decoder = new TextDecoder();
|
|
162
175
|
/**
|
|
163
176
|
* decoder for base64
|
|
177
|
+
*
|
|
178
|
+
* RFC 2045 requires decoders to ignore line breaks and decode the concatenated
|
|
179
|
+
* stream, so producers may wrap at any column. We strip whitespace and buffer
|
|
180
|
+
* characters that don't yet form a complete four-character quantum, flushing
|
|
181
|
+
* the remainder at the end of the part.
|
|
164
182
|
*/
|
|
165
183
|
export async function* decodeBase64(lines) {
|
|
184
|
+
let residual = "";
|
|
166
185
|
for await (const bytes of lines) {
|
|
167
|
-
|
|
186
|
+
residual += decoder.decode(bytes).replace(/\s/g, "");
|
|
187
|
+
const usable = residual.length - (residual.length % 4);
|
|
188
|
+
if (usable > 0) {
|
|
189
|
+
yield toByteArray(residual.slice(0, usable));
|
|
190
|
+
residual = residual.slice(usable);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (residual.length > 0) {
|
|
194
|
+
yield toByteArray(residual);
|
|
168
195
|
}
|
|
169
196
|
}
|
|
170
197
|
/**
|
|
171
198
|
* decoder for 7bit and 8bit
|
|
199
|
+
*
|
|
200
|
+
* 7bit/8bit apply no transfer transformation, so the content bytes are the
|
|
201
|
+
* payload as-is. parseMhtml splits the stream on CRLF to find part boundaries,
|
|
202
|
+
* so we re-insert `newLine` (defaulting to CRLF) between lines to restore the
|
|
203
|
+
* original bytes exactly. Pass `newLine` (e.g. a single "\n") to normalize line
|
|
204
|
+
* endings instead. The separator goes between lines; the CRLF before the
|
|
205
|
+
* boundary belongs to the delimiter, not the body.
|
|
172
206
|
*/
|
|
173
|
-
export async function* decodeIdentity(lines) {
|
|
207
|
+
export async function* decodeIdentity(lines, newLine = crlf) {
|
|
208
|
+
let first = true;
|
|
174
209
|
for await (const bytes of lines) {
|
|
210
|
+
if (first) {
|
|
211
|
+
first = false;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
yield newLine;
|
|
215
|
+
}
|
|
175
216
|
yield bytes;
|
|
176
217
|
}
|
|
177
218
|
}
|
|
@@ -182,5 +223,5 @@ export async function* decodeIdentity(lines) {
|
|
|
182
223
|
* error.
|
|
183
224
|
*/
|
|
184
225
|
export function decodeBinary() {
|
|
185
|
-
throw new Error("binary transfer-encoding is explicitly not supported and trying to add an implementation will likely result in unexpected results, but if you want to
|
|
226
|
+
throw new Error("binary transfer-encoding is explicitly not supported and trying to add an implementation will likely result in unexpected results, but if you want to handle it anyway, override `binary` in `decoderOverrides`");
|
|
186
227
|
}
|
package/dist/utils.spec.js
CHANGED
|
@@ -115,7 +115,13 @@ describe("decodeQuotedPrintable()", () => {
|
|
|
115
115
|
// eslint-disable-next-line spellcheck/spell-checker
|
|
116
116
|
const input = ["key=3Dvalue", "this line continues =", "on the next line"];
|
|
117
117
|
const res = decoder.decode(await collect(decodeQuotedPrintable(toAsyncIterable(input.map((l) => encoder.encode(l))))));
|
|
118
|
-
expect(res).toStrictEqual("key=value\nthis line continues on the next line
|
|
118
|
+
expect(res).toStrictEqual("key=value\r\nthis line continues on the next line");
|
|
119
|
+
});
|
|
120
|
+
test("normalizes to a custom separator when given one", async () => {
|
|
121
|
+
// eslint-disable-next-line spellcheck/spell-checker
|
|
122
|
+
const input = ["key=3Dvalue", "this line continues =", "on the next line"];
|
|
123
|
+
const res = decoder.decode(await collect(decodeQuotedPrintable(toAsyncIterable(input.map((l) => encoder.encode(l))), new Uint8Array([10]))));
|
|
124
|
+
expect(res).toStrictEqual("key=value\nthis line continues on the next line");
|
|
119
125
|
});
|
|
120
126
|
test("ascii failure", async () => {
|
|
121
127
|
const input = ["\xff"];
|
|
@@ -137,11 +143,33 @@ describe("decodeQuotedPrintable()", () => {
|
|
|
137
143
|
}
|
|
138
144
|
})()).rejects.toThrow("quoted printable escape");
|
|
139
145
|
});
|
|
146
|
+
test("non-hex escape failure", async () => {
|
|
147
|
+
const input = ["a=ZZb"];
|
|
148
|
+
const decoded = decodeQuotedPrintable(toAsyncIterable(input.map((l) => encoder.encode(l))));
|
|
149
|
+
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
150
|
+
await expect((async () => {
|
|
151
|
+
for await (const _ of decoded) {
|
|
152
|
+
//
|
|
153
|
+
}
|
|
154
|
+
})()).rejects.toThrow("two hex digits");
|
|
155
|
+
});
|
|
140
156
|
});
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
157
|
+
describe("decodeIdentity()", () => {
|
|
158
|
+
test("passes bytes through", async () => {
|
|
159
|
+
const input = ["line ", "not ascii \xff"];
|
|
160
|
+
const res = decoder.decode(await collect(decodeIdentity(toAsyncIterable(input.map((l) => encoder.encode(l))))));
|
|
161
|
+
expect(res).toStrictEqual("line \r\nnot ascii \xff");
|
|
162
|
+
});
|
|
163
|
+
test("reinserts CRLF between lines", async () => {
|
|
164
|
+
const input = ["line one", "line two", "line three"];
|
|
165
|
+
const res = decoder.decode(await collect(decodeIdentity(toAsyncIterable(input.map((l) => encoder.encode(l))))));
|
|
166
|
+
expect(res).toStrictEqual("line one\r\nline two\r\nline three");
|
|
167
|
+
});
|
|
168
|
+
test("normalizes to a custom separator when given one", async () => {
|
|
169
|
+
const input = ["line one", "line two"];
|
|
170
|
+
const res = decoder.decode(await collect(decodeIdentity(toAsyncIterable(input.map((l) => encoder.encode(l))), new Uint8Array([10]))));
|
|
171
|
+
expect(res).toStrictEqual("line one\nline two");
|
|
172
|
+
});
|
|
145
173
|
});
|
|
146
174
|
test("decodeBinary()", () => {
|
|
147
175
|
expect(decodeBinary).toThrow("explicitly");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mhtml-stream",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Streaming MHTML parser",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mhtml",
|
|
7
7
|
"stream"
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/erikbrinkman/mhtml-stream.git"
|
|
12
12
|
},
|
|
13
|
+
"homepage": "https://erikbrinkman.github.io/mhtml-stream/",
|
|
13
14
|
"author": "Erik Brinkman <erik.brinkman@gmail.com>",
|
|
14
15
|
"license": "MIT",
|
|
15
16
|
"type": "module",
|