mhtml-to-html
Version:
JavaScript library for parsing MHTML files and converting them into single HTML files
570 lines (544 loc) • 24.7 kB
JavaScript
import {
decodeQuotedPrintable,
decodeBinary,
decodeBase64Bytes,
parseDOM,
decodeString,
encodeString,
getCharset,
getBomCharset,
replaceCharset,
isDocument,
isStylesheet,
isText,
isMultipart,
isMultipartAlternative,
normalizeLocation,
getBoundary,
indexOf,
startsWithBoundary,
isLineFeed,
endsWithCRLF,
endsWithLF
} from "./util.js";
import * as cssTree from "./vendor/csstree.esm.js";
const MHTML_HEADERS = 0;
const MHTML_CONTENT = 1;
const MHTML_DATA = 2;
const MHTML_END = 3;
const STRING_TYPE = "string";
const HEADER_SEPARATOR = ":";
const QUOTED_PRINTABLE_ENCODING = "quoted-printable";
const BINARY_ENCODING = "binary";
const CONTENT_TYPE_HEADER = "content-type";
const CONTENT_TRANSFER_ENCODING_HEADER = "content-transfer-encoding";
const CONTENT_ID_HEADER = "content-id";
const CONTENT_LOCATION_HEADER = "content-location";
const BASE64_ENCODING = "base64";
const UTF8_CHARSET = "utf-8";
const META_TAG = "META";
const CONTENT_ATTRIBUTE = "content";
const CHARSET_ATTRIBUTE = "charset";
const HTTP_EQUIV_ATTRIBUTE = "http-equiv";
const AT_RULE = "Atrule";
const CHARSET_IDENTIFIER = "charset";
const GENERATED_ID_PREFIX = "_";
const TEXT_HTML_CONTENT_TYPE = "text/html";
const LESS_THAN_SIGN = 0x3C;
// RFC 5322: a field name is printable ASCII without the colon; a folded value continues indented
const HEADER_NAME_REGEXP = /^[!-9;-~]+:/;
const HEADER_CONTINUATION_REGEXP = /^[ \t]/;
const REPLACEMENT_CHARACTER = "�";
const LINE_FEED = 0x0A;
const CARRIAGE_RETURN = 0x0D;
const HYPHEN_MINUS = 0x2D;
const SPACE = 0x20;
const HORIZONTAL_TAB = 0x09;
const EQUAL_SIGN = 0x3D;
const NUL = 0x00;
export default parse;
function parse(mhtml, { DOMParser } = { DOMParser: globalThis.DOMParser }, context = { resources: {}, frames: {} }) {
// deno-lint-ignore valid-typeof
if (typeof mhtml === STRING_TYPE) {
mhtml = encodeString(mhtml);
}
const headers = {};
// kept to decode again the headers a non-conforming writer emitted as raw bytes (see decodeRawHeaders)
const rawHeaderLines = [];
const { resources, frames } = context;
let resource, transferEncoding, contentId, boundary, boundaryBytes, headerKey;
let content = {};
let state = MHTML_HEADERS;
let indexMhtml = 0;
let indexGeneratedId = 0;
let indexStartEmbeddedMhtml;
// the extension is not a promise: Word saves plain HTML as .mht, and its markup would otherwise
// be read as headers, the "urn:" prefixes of its namespaces passing for header names
if (startsWithMarkup(mhtml)) {
headers[CONTENT_TYPE_HEADER] = TEXT_HTML_CONTENT_TYPE;
initResource(headers);
appendData(resource.data, mhtml);
processResource();
return { headers, frames, resources, index: context.index };
}
// the last line counts: a part whose body is empty ends the file on the blank line closing its
// headers, and stopping a byte short would drop it — the document itself, in a file cut off there.
// Once that line is read the resource still has to be recorded, hence the second condition.
while (state !== MHTML_END && (indexMhtml < mhtml.length || state === MHTML_DATA)) {
let next;
if (state === MHTML_HEADERS) {
next = getLine();
if (!isLineFeed(next)) {
rawHeaderLines.push(next);
splitHeaders(next, headers);
} else {
if (headers[CONTENT_TYPE_HEADER]) {
setBoundary(getBoundary(headers[CONTENT_TYPE_HEADER]));
}
if (boundary) {
const indexStartBody = indexMhtml;
while (findBoundaryDelimiter(next, boundaryBytes) === -1 && indexMhtml < mhtml.length - 1) {
next = getLine();
}
// the declared boundary is not always the one the body uses: rather than read
// the whole file as a single part, go back and take the one it does use
if (findBoundaryDelimiter(next, boundaryBytes) === -1) {
indexMhtml = indexStartBody;
setBoundary(undefined);
}
}
if (!boundary) {
const previousIndex = indexMhtml;
next = getLine(transferEncoding);
if (startsWithBoundary(next)) {
setBoundary(decodeString(next).substring(2).trimEnd());
} else {
indexMhtml = previousIndex;
}
}
content = {};
state = MHTML_CONTENT;
}
} else if (state === MHTML_CONTENT) {
if (boundary) {
if (indexStartEmbeddedMhtml === undefined) {
indexStartEmbeddedMhtml = indexMhtml;
}
next = getLine();
if (!isLineFeed(next)) {
splitHeaders(next, content);
} else {
initResource(content);
if (!resource.contentType || !isMultipartAlternative(resource.contentType)) {
indexStartEmbeddedMhtml = undefined;
}
state = MHTML_DATA;
}
} else {
// with no boundary the body is everything that is left, and headers describing a
// container say nothing about it, so the body has to speak for itself
initResource(isMultipart(headers[CONTENT_TYPE_HEADER]) ? readBodyHeaders() : headers);
state = MHTML_DATA;
}
} else if (state === MHTML_DATA) {
const indexEndData = parseResourceData();
if (indexStartEmbeddedMhtml !== undefined && indexEndData !== undefined) {
resource.data = flattenData(resource.data);
resource.used = true;
context.index = convertEmbeddedMhtml(indexEndData);
} else {
processResource();
}
state = (indexMhtml >= mhtml.length - 1 ? MHTML_END : MHTML_CONTENT);
}
}
return { headers, frames, resources, index: context.index };
function setBoundary(value) {
boundary = value;
boundaryBytes = value === undefined ? undefined : encodeString(value);
}
function getLine(transferEncoding) {
const indexStart = indexMhtml;
const indexLineFeed = mhtml.indexOf(LINE_FEED, indexStart);
indexMhtml = indexLineFeed === -1 ? mhtml.length + 1 : indexLineFeed + 1;
// a view, not a copy: every line is read or transformed, never written to
const line = mhtml.subarray(indexStart, indexMhtml);
return transferEncoding === QUOTED_PRINTABLE_ENCODING ? decodeQuotedPrintable(line) : line;
}
function splitHeaders(line, obj) {
headerKey = parseHeaderLine(decodeString(line), obj, headerKey);
}
// RFC 5322 headers must be ASCII, but a localized writer can emit raw bytes in e.g. "From:" (IE
// does it for the "Saved by ..." value). They are decoded as UTF-8 while the charset of the
// document is still unknown, so decode them again once it is, and keep the ones that survived.
function decodeRawHeaders(charset) {
if (charset === undefined || charset === UTF8_CHARSET ||
!Object.values(headers).some(value => value.includes(REPLACEMENT_CHARACTER))) {
return;
}
const decodedHeaders = {};
let decodedHeaderKey;
for (const line of rawHeaderLines) {
decodedHeaderKey = parseHeaderLine(decodeString(line, charset), decodedHeaders, decodedHeaderKey);
}
for (const [name, value] of Object.entries(headers)) {
if (value.includes(REPLACEMENT_CHARACTER) && decodedHeaders[name] !== undefined) {
headers[name] = decodedHeaders[name];
}
}
}
// The delimiters a multipart document promised never turned up. What follows the top-level
// headers is then either the headers of the one part left, or its body already — so take
// whichever it looks like, and give up on the container type either way.
function readBodyHeaders() {
const indexStartBody = indexMhtml;
if (startsWithMarkup(mhtml.subarray(indexMhtml))) {
return { [CONTENT_TYPE_HEADER]: TEXT_HTML_CONTENT_TYPE };
}
const bodyHeaders = {};
headerKey = undefined;
let indexBeforeLine = indexMhtml;
let next = getLine();
while (next.length && !isLineFeed(next) && isHeaderLine(next, Object.keys(bodyHeaders).length)) {
splitHeaders(next, bodyHeaders);
indexBeforeLine = indexMhtml;
next = getLine();
}
if (bodyHeaders[CONTENT_TYPE_HEADER] === undefined) {
indexMhtml = indexStartBody;
return headers;
}
if (!isLineFeed(next)) {
// the line was already part of the body
indexMhtml = indexBeforeLine;
}
return bodyHeaders;
}
function isHeaderLine(line, countHeaders) {
const value = decodeString(line);
return HEADER_NAME_REGEXP.test(value) || (countHeaders > 0 && HEADER_CONTINUATION_REGEXP.test(value));
}
function initResource(resourceData) {
transferEncoding = resourceData[CONTENT_TRANSFER_ENCODING_HEADER];
const contentType = resourceData[CONTENT_TYPE_HEADER];
contentId = resourceData[CONTENT_ID_HEADER];
let id = resourceData[CONTENT_LOCATION_HEADER];
if (transferEncoding) {
transferEncoding = transferEncoding.toLowerCase();
}
resource = {
transferEncoding,
contentType,
data: { chunks: [], length: 0 },
id
};
if (id === undefined) {
if (contentId !== undefined) {
id = contentId;
} else {
do {
id = GENERATED_ID_PREFIX + indexGeneratedId++;
} while (resources[id]);
}
}
const writtenId = id;
id = normalizeLocation(id);
resource.id = id;
if (context.index === undefined && isDocument(contentType)) {
context.index = id;
}
if (contentId !== undefined) {
frames[contentId] = resource;
// also reachable as a resource, so that a cid: reference outside a frame finds it
if (!resources[contentId]) {
resources[contentId] = resource;
}
}
if (!resources[id]) {
resources[id] = resource;
}
// the address as it was written stays reachable, for a reference that is never normalized
if (writtenId !== id && !resources[writtenId]) {
resources[writtenId] = resource;
}
content = {};
headerKey = undefined;
}
function parseResourceData() {
let next = getLine(transferEncoding);
let indexEndData, boundaryFound;
while (!boundaryFound && next.length) {
indexEndData = indexMhtml;
const indexBoundary = findBoundaryDelimiter(next, boundaryBytes);
if (indexBoundary !== -1) {
indexEndData = indexEndData - next.length + indexBoundary - 2;
if (indexBoundary > 2) {
next = next.subarray(0, indexBoundary - 2);
} else {
next = [];
}
boundaryFound = true;
}
if (resource.transferEncoding === QUOTED_PRINTABLE_ENCODING) {
if (resource.data.length > 2 && getDataByte(resource.data, 3) === EQUAL_SIGN && endsWithCRLF(next)) {
truncateData(resource.data, 3);
} else if (resource.data.length > 1 && getDataByte(resource.data, 2) === EQUAL_SIGN && endsWithLF(next)) {
truncateData(resource.data, 2);
}
} else if (resource.transferEncoding === BASE64_ENCODING) {
if (endsWithCRLF(next)) {
next = next.subarray(0, next.length - 2);
} else if (endsWithLF(next)) {
next = next.subarray(0, next.length - 1);
}
}
appendData(resource.data, next);
if (!boundaryFound) {
next = getLine(transferEncoding);
}
}
truncateDataLineTerminator(resource.data);
if (!boundaryFound && boundary) {
indexEndData = indexMhtml;
}
return indexEndData;
}
function convertEmbeddedMhtml(indexEnd) {
const context = { resources, frames };
const embeddedMhtml = mhtml.subarray(indexStartEmbeddedMhtml, indexEnd);
if (endsWithCRLF(embeddedMhtml)) {
indexEnd -= 2;
} else if (endsWithLF(embeddedMhtml)) {
indexEnd--;
}
parse(mhtml.subarray(indexStartEmbeddedMhtml, indexEnd), { DOMParser }, context);
return context.index;
}
function processResource() {
resource.data = resource.rawData = flattenData(resource.data);
const declaredCharset = resource.contentType ? getCharset(resource.contentType) : undefined;
// the parts shown as text — the main document, the framed documents and the stylesheets —
// must be decoded here, otherwise the charset they declare cannot be detected and they are
// decoded as UTF-8. Every other part is kept encoded: it is inlined as a data URI and must
// stay byte-exact, even when it is mislabeled as text (e.g. a font served as text/plain, or
// a photo served as text/html) — which is why a document is only decoded when a frame will
// actually show it, that is when it is the index or carries a Content-ID.
if (resource.transferEncoding === BASE64_ENCODING && resource.contentType &&
(resource.id === context.index ||
(contentId !== undefined && isDocument(resource.contentType)) ||
isStylesheet(resource.contentType))) {
const decodedData = decodeBase64Bytes(decodeString(resource.data));
if (decodedData !== undefined) {
resource.transferEncoding = undefined;
resource.data = resource.rawData = decodedData;
}
}
// read once the part is whole: a base64 stylesheet only shows its mark after being decoded
const bomCharset = getBomCharset(resource.data);
const charset = bomCharset || declaredCharset;
if (resource.transferEncoding === BINARY_ENCODING && (!resource.contentType || !isText(resource.contentType))) {
resource.transferEncoding = BASE64_ENCODING;
resource.data = decodeBinary(resource.data);
} else {
resource.data = decodeString(resource.data, charset);
}
if (resource.contentType) {
// the charset parameter is rewritten only when the bytes were transcoded: a part kept
// encoded still carries the bytes as they were written, and relabeling them utf-8 would
// put a lie in the data URI it is inlined as
if (resource.transferEncoding !== BASE64_ENCODING) {
resource.contentType = replaceCharset(resource.contentType, UTF8_CHARSET);
}
if (isStylesheet(resource.contentType)) {
processStylesheetCharset(charset, bomCharset);
} else if (isDocument(resource.contentType)) {
const documentCharset = processDocumentCharset(charset, bomCharset);
if (resource.id === context.index) {
decodeRawHeaders(documentCharset);
}
}
}
delete resource.rawData;
}
// the rule is removed whatever happens: it described the bytes as they were written, and says
// nothing true about the UTF-8 the sheet is inlined as. Only the re-reading it can ask for is
// refused when a byte order mark has already settled the question.
function processStylesheetCharset(charset, bomCharset) {
try {
let ast = cssTree.parse(resource.data);
if (ast.children.first && ast.children.first.type === AT_RULE && ast.children.first.name.toLowerCase() === CHARSET_IDENTIFIER) {
const charsetNode = ast.children.first;
const cssCharset = charsetNode.prelude.children.first.value.toLowerCase();
if (bomCharset === undefined && cssCharset !== UTF8_CHARSET && cssCharset !== charset) {
resource.data = decodeString(resource.rawData, cssCharset);
ast = cssTree.parse(resource.data);
}
ast.children.remove(ast.children.head);
resource.data = cssTree.generate(ast);
}
// eslint-disable-next-line no-unused-vars
} catch (_) {
// ignored
}
}
function processDocumentCharset(charset, bomCharset) {
let documentCharset = charset;
let modified = false;
const contentType = resource.contentType.split(";")[0];
let dom = parseDOM(resource.data, contentType, DOMParser);
// the first declaration is the one a browser would have obeyed, so it alone decides how the
// bytes are read; the others only have to go
let charsetMetaElements = getMetaCharsetElements(dom.document.documentElement);
if (charsetMetaElements.length) {
let htmlCharset = charsetMetaElements[0].getAttribute(CHARSET_ATTRIBUTE);
if (htmlCharset) {
htmlCharset = htmlCharset.toLowerCase();
if (bomCharset === undefined && htmlCharset !== UTF8_CHARSET && htmlCharset !== charset) {
resource.data = decodeString(resource.rawData, htmlCharset);
documentCharset = htmlCharset;
dom = parseDOM(resource.data, contentType, DOMParser);
charsetMetaElements = getMetaCharsetElements(dom.document.documentElement);
}
}
charsetMetaElements.forEach(metaElement => metaElement.remove());
modified = true;
}
let metaElements = getMetaContentTypeElements(dom.document.documentElement);
if (metaElements.length) {
const contentType = metaElements[0].getAttribute(CONTENT_ATTRIBUTE);
const htmlCharset = getCharset(contentType);
// measured against documentCharset, not the declared one: a document saying the same
// thing twice — a meta charset then a meta http-equiv — has already been re-read above
if (bomCharset === undefined && htmlCharset && htmlCharset !== UTF8_CHARSET && htmlCharset !== documentCharset) {
resource.data = decodeString(resource.rawData, htmlCharset);
documentCharset = htmlCharset;
dom = parseDOM(resource.data, contentType, DOMParser);
metaElements = getMetaContentTypeElements(dom.document.documentElement);
// re-reading the raw bytes brought back the meta charset elements removed above
getMetaCharsetElements(dom.document.documentElement).forEach(metaElement => metaElement.remove());
}
metaElements.forEach(metaElement => metaElement.remove());
modified = true;
}
// one serialization at the end: with both kinds of declaration present it used to run twice
if (modified) {
resource.data = dom.serialize();
}
return documentCharset;
}
}
function appendData(data, chunk) {
if (chunk.length) {
data.chunks.push(chunk);
data.length += chunk.length;
}
}
function getDataByte(data, offsetFromEnd) {
let offset = offsetFromEnd;
for (let indexChunk = data.chunks.length - 1; indexChunk >= 0; indexChunk--) {
const chunk = data.chunks[indexChunk];
if (chunk.length >= offset) {
return chunk[chunk.length - offset];
}
offset -= chunk.length;
}
}
// the line terminator preceding a boundary delimiter belongs to the delimiter, not to the resource
// a delimiter line is "--" followed by the boundary, an optional "--" closing the multipart body,
// then only transport padding: a line merely starting with the boundary is not a delimiter
function findBoundaryDelimiter(line, boundaryBytes) {
const indexBoundary = indexOf(line, boundaryBytes);
if (indexBoundary >= 2 && line[indexBoundary - 2] === HYPHEN_MINUS && line[indexBoundary - 1] === HYPHEN_MINUS) {
let index = indexBoundary + boundaryBytes.length;
if (line[index] === HYPHEN_MINUS && line[index + 1] === HYPHEN_MINUS) {
index += 2;
}
while (line[index] === SPACE || line[index] === HORIZONTAL_TAB) {
index++;
}
if (index >= line.length || line[index] === CARRIAGE_RETURN || line[index] === LINE_FEED) {
return indexBoundary;
}
}
return -1;
}
function truncateDataLineTerminator(data) {
if (data.length > 1 && getDataByte(data, 2) === CARRIAGE_RETURN && getDataByte(data, 1) === LINE_FEED) {
truncateData(data, 2);
} else if (data.length > 0 && getDataByte(data, 1) === LINE_FEED) {
truncateData(data, 1);
}
}
function truncateData(data, count) {
data.length -= count;
let remaining = count;
while (remaining) {
const chunk = data.chunks[data.chunks.length - 1];
if (chunk.length > remaining) {
data.chunks[data.chunks.length - 1] = chunk.subarray(0, chunk.length - remaining);
remaining = 0;
} else {
data.chunks.pop();
remaining -= chunk.length;
}
}
}
function flattenData(data) {
const result = new Uint8Array(data.length);
let offset = 0;
for (const chunk of data.chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
// a document begins with a tag, whitespace aside; anything else is read as an archive, so that a
// file which is neither is still reported rather than turned into a page of nonsense
function startsWithMarkup(array) {
// a byte order mark only says how the text is encoded, so it is skipped; after a UTF-16 mark
// the NUL half of each character is skipped too, whichever end it comes on
const bomCharset = getBomCharset(array);
let index = 0;
let skipNul = false;
if (bomCharset === UTF8_CHARSET) {
index = 3;
} else if (bomCharset !== undefined) {
index = 2;
skipNul = true;
}
for (; index < array.length; index++) {
const byte = array[index];
if (byte === LESS_THAN_SIGN) {
return true;
}
if (skipNul && byte === NUL) {
continue;
}
if (byte !== SPACE && byte !== HORIZONTAL_TAB && byte !== CARRIAGE_RETURN && byte !== LINE_FEED) {
return false;
}
}
return false;
}
function parseHeaderLine(lineString, obj, headerKey) {
const indexColumn = lineString.indexOf(HEADER_SEPARATOR);
if (indexColumn > -1) {
headerKey = lineString.substring(0, indexColumn).trim().toLowerCase();
obj[headerKey] = lineString.substring(indexColumn + 1, lineString.length).trim();
} else if (headerKey !== undefined && obj[headerKey] !== undefined) {
obj[headerKey] += lineString.trim();
}
return headerKey;
}
// all of them, not just the first: a page assembled from several templates declares its charset
// once per template, and a declaration left behind would outlive the bytes it described — the
// document is inlined as UTF-8, so any survivor is a lie about the text around it
function getMetaCharsetElements(document) {
const metaElements = document.getElementsByTagName(META_TAG);
return Array.from(metaElements).filter(metaElement => metaElement.getAttribute(CHARSET_ATTRIBUTE));
}
function getMetaContentTypeElements(document) {
const metaElements = document.getElementsByTagName(META_TAG);
return Array.from(metaElements).filter(metaElement => metaElement.getAttribute(HTTP_EQUIV_ATTRIBUTE)
&& metaElement.getAttribute(HTTP_EQUIV_ATTRIBUTE).toLowerCase() === CONTENT_TYPE_HEADER.toLowerCase());
}