UNPKG

mhtml-to-html

Version:

JavaScript library for parsing MHTML files and converting them into single HTML files

295 lines (281 loc) 11.6 kB
import { decodeQuotedPrintable, decodeBinary, parseDOM, decodeString, encodeString, getCharset, replaceCharset, isDocument, isStylesheet, isText, isMultipartAlternative, getBoundary, indexOf, startsWithBoundary, isLineFeed, endsWithCRLF, endsWithLF } from "./util.js"; import * as cssTree from "./vendor/csstree.esm.js"; const MHTML_HEADERS = 0; const MTHML_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 RANDOM_ID_PREFIX = "_"; 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 = {}; const { resources, frames } = context; let resource, transferEncoding, boundary, headerKey; let content = {}; let state = MHTML_HEADERS; let indexMhtml = 0; let indexStartEmbeddedMhtml; while (state !== MHTML_END && indexMhtml < mhtml.length - 1) { let next; if (state === MHTML_HEADERS) { next = getLine(); if (!isLineFeed(next)) { splitHeaders(next, headers); } else { boundary = getBoundary(headers[CONTENT_TYPE_HEADER]); if (boundary) { while (indexOf(next, boundary) === -1 && indexMhtml < mhtml.length - 1) { next = getLine(); } } else { const previousIndex = indexMhtml; next = getLine(transferEncoding); if (!boundary && startsWithBoundary(next)) { boundary = decodeString(next); } else { indexMhtml = previousIndex; } } content = {}; state = MTHML_CONTENT; } } else if (state === MTHML_CONTENT) { if (boundary) { if (indexStartEmbeddedMhtml === undefined) { indexStartEmbeddedMhtml = indexMhtml; } next = getLine(); if (!isLineFeed(next)) { splitHeaders(next, content); } else { initResource(content); if (!isMultipartAlternative(resource.contentType)) { indexStartEmbeddedMhtml = undefined; } state = MHTML_DATA; } } else { initResource(headers); state = MHTML_DATA; } } else if (state === MHTML_DATA) { const indexEndData = parseResourceData(); if (indexStartEmbeddedMhtml !== undefined && indexEndData !== undefined) { resource.used = true; context.index = convertEmbeddedMhtml(indexEndData); } else { processResource(); } state = (indexMhtml >= mhtml.length - 1 ? MHTML_END : MTHML_CONTENT); } } return { headers, frames, resources, index: context.index }; function getLine(transferEncoding) { const indexStart = indexMhtml; while (!isLineFeed([mhtml[indexMhtml]]) && indexMhtml++ < mhtml.length - 1); indexMhtml++; const line = mhtml.slice(indexStart, indexMhtml); return transferEncoding === QUOTED_PRINTABLE_ENCODING ? decodeQuotedPrintable(line) : line; } function splitHeaders(line, obj) { const lineString = decodeString(line); const indexColumn = lineString.indexOf(HEADER_SEPARATOR); if (indexColumn > -1) { headerKey = lineString.substring(0, indexColumn).trim(); obj[headerKey] = lineString.substring(indexColumn + 1, lineString.length).trim(); } else { obj[headerKey] += lineString.trim(); } } function initResource(resourceData) { transferEncoding = resourceData[CONTENT_TRANSFER_ENCODING_HEADER]; const contentType = resourceData[CONTENT_TYPE_HEADER]; const contentId = resourceData[CONTENT_ID_HEADER]; let id = resourceData[CONTENT_LOCATION_HEADER]; if (transferEncoding) { transferEncoding = transferEncoding.toLowerCase(); } resource = { transferEncoding, contentType, data: [], id }; if (id === undefined) { if (contentId !== undefined) { id = contentId; } else { do { id = RANDOM_ID_PREFIX + Math.random().toString(36).substring(2); } while (resources[id]); } resource.id = id; } if (context.index === undefined && isDocument(contentType)) { context.index = id; } if (contentId !== undefined) { frames[contentId] = resource; } if (!resources[id]) { resources[id] = resource; } content = {}; } function parseResourceData() { let next = getLine(transferEncoding); let indexEndData, boundaryFound; while (!boundaryFound && indexMhtml < mhtml.length - 1) { indexEndData = indexMhtml; const indexBoundary = indexOf(next, boundary); if (indexBoundary !== -1) { indexEndData = indexEndData - next.length + indexBoundary - 2; if (indexBoundary > 2) { next = next.slice(0, indexBoundary - 2); } else { next = []; } boundaryFound = true; } if (resource.transferEncoding === QUOTED_PRINTABLE_ENCODING) { if (resource.data.length > 2 && resource.data[resource.data.length - 3] === 0x3D && endsWithCRLF(next)) { resource.data.splice(resource.data.length - 3, 3); } else if (resource.data.length > 1 && resource.data[resource.data.length - 2] === 0x3D && endsWithLF(next)) { resource.data.splice(resource.data.length - 2, 2); } } else if (resource.transferEncoding === BASE64_ENCODING) { if (endsWithCRLF(next)) { next = next.slice(0, next.length - 2); } else if (endsWithLF(next)) { next = next.slice(0, next.length - 1); } } resource.data.splice(resource.data.length, 0, ...next); if (!boundaryFound) { next = getLine(transferEncoding); } } return indexEndData; } function convertEmbeddedMhtml(indexEnd) { const context = { resources, frames }; if (endsWithCRLF(mhtml)) { indexEnd -= 2; } else if (endsWithLF(mhtml)) { indexEnd--; } parse(mhtml.slice(indexStartEmbeddedMhtml, indexEnd), { DOMParser }, context); return context.index; } function processResource() { resource.data = resource.rawData = new Uint8Array(resource.data); const charset = getCharset(resource.contentType); if (resource.transferEncoding === BINARY_ENCODING && !isText(resource.contentType)) { resource.transferEncoding = BASE64_ENCODING; resource.data = decodeBinary(resource.data); } else { resource.data = decodeString(resource.data, charset); } resource.contentType = replaceCharset(resource.contentType, UTF8_CHARSET); if (isStylesheet(resource.contentType)) { processStylesheetCharset(charset); } else if (isDocument(resource.contentType)) { processDocumentCharset(charset); } delete resource.rawData; } function processStylesheetCharset(charset) { 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 (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) { const contentType = resource.contentType.split(";")[0]; let dom = parseDOM(resource.data, contentType, DOMParser); let charserMetaElement = getMetaCharsetElement(dom.document.documentElement); if (charserMetaElement) { let htmlCharset = charserMetaElement.getAttribute(CHARSET_ATTRIBUTE); if (htmlCharset) { htmlCharset = htmlCharset.toLowerCase(); if (htmlCharset !== UTF8_CHARSET && htmlCharset !== charset) { resource.data = decodeString(resource.rawData, charset); dom = parseDOM(resource.data, contentType, DOMParser); charserMetaElement = getMetaCharsetElement(dom.document); } } charserMetaElement.remove(); resource.data = dom.serialize(); } let metaElement = getMetaContentTypeElement(dom.document); if (metaElement) { const contentType = metaElement.getAttribute(CONTENT_ATTRIBUTE); const htmlCharset = getCharset(contentType); if (htmlCharset && htmlCharset !== UTF8_CHARSET && htmlCharset !== charset) { resource.data = decodeString(resource.rawData, htmlCharset); dom = parseDOM(resource.data, contentType, DOMParser); metaElement = getMetaContentTypeElement(dom.document.documentElement); } metaElement.remove(); resource.data = dom.serialize(); } } } function getMetaCharsetElement(document) { const metaElements = document.getElementsByTagName(META_TAG); return Array.from(metaElements).find(metaElement => metaElement.getAttribute(CHARSET_ATTRIBUTE)); } function getMetaContentTypeElement(document) { const metaElements = document.getElementsByTagName(META_TAG); return Array.from(metaElements).find(metaElement => metaElement.getAttribute(HTTP_EQUIV_ATTRIBUTE) && metaElement.getAttribute(HTTP_EQUIV_ATTRIBUTE).toLowerCase() === CONTENT_TYPE_HEADER.toLowerCase()); }