mhtml-to-html
Version:
JavaScript library for parsing MHTML files and converting them into single HTML files
114 lines (101 loc) • 6.69 kB
JavaScript
// How a reference in the document is matched against a part of the archive. A Content-Location is
// written by hand and a reference is resolved with the URL parser, so the two only meet if the
// archive stores addresses the same way references will be resolved.
import { test } from "node:test";
import assert from "node:assert/strict";
import { parse, convert } from "./helpers/lib.js";
import { encodeUtf8 } from "./helpers/mhtml.js";
const BOUNDARY = "----=_B";
const DOCUMENT_LOCATION = "https://e/dir/page.html";
const PNG = "iVBORw0KGgo=";
const PNG_URI = `data:image/png;base64,${PNG}`;
// Everything is encoded as UTF-8, so a header can carry a non-ASCII address the way a writer emits it.
function build({ reference, location, contentId, documentLocation = DOCUMENT_LOCATION, parts = [] }) {
const image = [
`--${BOUNDARY}`, "Content-Type: image/png", "Content-Transfer-Encoding: base64",
...(location === undefined ? [] : [`Content-Location: ${location}`]),
...(contentId === undefined ? [] : [`Content-ID: ${contentId}`]), "", PNG
].join("\r\n");
return encodeUtf8([
"MIME-Version: 1.0", `Content-Type: multipart/related; boundary="${BOUNDARY}"`, "",
`--${BOUNDARY}`, "Content-Type: text/html; charset=utf-8", "Content-Transfer-Encoding: 8bit",
`Content-Location: ${documentLocation}`, "",
`<html><body><img src="${reference}"></body></html>`,
image, ...parts, `--${BOUNDARY}--`, ""
].join("\r\n"));
}
const matches = async options => (await convert(build(options))).data.includes(PNG_URI);
const MATCHING = [
["a plain relative reference", "i.png", "https://e/dir/i.png"],
["a relative Content-Location", "i.png", "i.png"],
["dot segments in the Content-Location", "i.png", "https://e/dir/sub/../i.png"],
["dot segments in the reference", "sub/../i.png", "https://e/dir/i.png"],
["an explicit default port", "i.png", "https://e:443/dir/i.png"],
["an upper case host", "i.png", "https://E/dir/i.png"],
["a space encoded on both sides", "my%20image.png", "https://e/dir/my%20image.png"],
["a space written raw in the Content-Location", "my%20image.png", "https://e/dir/my image.png"],
["a space written raw on both sides", "my image.png", "https://e/dir/my image.png"],
["a non-ASCII address written raw on both sides", "café.png", "https://e/dir/café.png"],
["a non-ASCII address encoded in the reference only", "caf%C3%A9.png", "https://e/dir/café.png"],
["Cyrillic encoded in the reference only", "%D1%84.png", "https://e/dir/ф.png"],
["the same query on both sides", "i.png?v=2", "https://e/dir/i.png?v=2"]
];
for (const [name, reference, location] of MATCHING) {
test(`a reference is matched through ${name}`, async () => {
assert.equal(await matches({ reference, location }), true, `${reference} did not find ${location}`);
});
}
const NOT_MATCHING = [
// a fragment selects a place inside a resource: an SVG sprite is addressed as sprite.svg#icon
// once per icon, and matching it would inline the whole sheet every time and lose the fragment
["a fragment the part does not carry", "i.png#icon", "https://e/dir/i.png"],
// a query is part of the address: nothing says the two answer with the same bytes
["a query the part does not carry", "i.png?v=2", "https://e/dir/i.png"],
// percent escapes are case insensitive in RFC 3986, but the URL parser does not normalize them
["escapes differing only in case", "a%2Fb.png", "https://e/dir/a%2fb.png"]
];
for (const [name, reference, location] of NOT_MATCHING) {
test(`a reference is deliberately not matched through ${name}`, async () => {
assert.equal(await matches({ reference, location }), false, "behaviour changed for " + reference);
});
}
test("a cid: reference outside a frame finds the part by its Content-ID", async () => {
assert.equal(await matches({ reference: "cid:x@y", contentId: "<x@y>" }), true);
});
test("a cid: reference inside a stylesheet finds the part too", async () => {
const raw = encodeUtf8([
"MIME-Version: 1.0", `Content-Type: multipart/related; boundary="${BOUNDARY}"`, "",
`--${BOUNDARY}`, "Content-Type: text/html; charset=utf-8", "Content-Transfer-Encoding: 8bit",
`Content-Location: ${DOCUMENT_LOCATION}`, "",
"<html><head><style>p{background:url(cid:x@y)}</style></head><body><p>x</p></body></html>",
`--${BOUNDARY}`, "Content-Type: image/png", "Content-Transfer-Encoding: base64",
"Content-ID: <x@y>", "", PNG, `--${BOUNDARY}--`, ""
].join("\r\n"));
assert.ok((await convert(raw)).data.includes(PNG_URI), "the stylesheet reference was not resolved");
});
test("a Content-Location is what identifies a part, even when it also has a Content-ID", async () => {
const { resources } = parse(build({ reference: "i.png", location: "https://e/dir/i.png", contentId: "<x@y>" }));
assert.ok(resources["https://e/dir/i.png"], "the part is not keyed by its location");
assert.ok(resources["<x@y>"], "the part is not reachable by its Content-ID");
assert.equal(resources["https://e/dir/i.png"], resources["<x@y>"], "the two addresses gave different parts");
assert.equal(resources["https://e/dir/i.png"].id, "https://e/dir/i.png", "the location is not the identity");
});
test("an address is stored the way a reference to it resolves", () => {
const { resources } = parse(build({ reference: "i.png", location: "https://E:443/dir/sub/../i.png" }));
assert.ok(resources["https://e/dir/i.png"], "the address was not normalized");
assert.ok(resources["https://E:443/dir/sub/../i.png"], "the address as written is no longer reachable");
});
test("of two parts sharing an address, the first one wins", async () => {
const raw = encodeUtf8([
"MIME-Version: 1.0", `Content-Type: multipart/related; boundary="${BOUNDARY}"`, "",
`--${BOUNDARY}`, "Content-Type: text/html; charset=utf-8", "Content-Transfer-Encoding: 8bit",
`Content-Location: ${DOCUMENT_LOCATION}`, "", "<html><body><img src=\"i.png\"></body></html>",
`--${BOUNDARY}`, "Content-Type: text/plain", "Content-Transfer-Encoding: 8bit",
"Content-Location: https://e/dir/i.png", "", "FIRST",
`--${BOUNDARY}`, "Content-Type: text/plain", "Content-Transfer-Encoding: 8bit",
"Content-Location: https://e/dir/i.png", "", "SECOND", `--${BOUNDARY}--`, ""
].join("\r\n"));
const { resources } = parse(raw);
assert.equal(resources["https://e/dir/i.png"].data, "FIRST");
assert.ok((await convert(raw)).data.includes(btoa("FIRST")), "the second part was inlined instead");
});