mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
/**
|
|
* HTML mail as plain text, for "show as plain text" when a message has no
|
|
* text/plain part of its own, and for read aloud and the reader view.
|
|
*
|
|
* Layout tables become lines, headings and paragraphs keep a blank line
|
|
* around them, list items get a bullet, images are replaced by their alt text
|
|
* and a link keeps its target in brackets after its text, so nothing the
|
|
* formatted mail offered is lost.
|
|
*/
|
|
|
|
const BLOCK = new Set([
|
|
'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'CENTER', 'DD', 'DETAILS', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION',
|
|
'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE',
|
|
'SECTION', 'SUMMARY', 'TABLE', 'TBODY', 'TFOOT', 'THEAD', 'TR', 'UL',
|
|
]);
|
|
/** Blocks that get a blank line before and after, not just a line break. */
|
|
const PARAGRAPH = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE', 'UL', 'OL', 'TABLE', 'HR']);
|
|
const SKIP = new Set(['HEAD', 'STYLE', 'SCRIPT', 'TEMPLATE', 'NOSCRIPT', 'TITLE', 'META', 'LINK', 'SVG', 'OBJECT', 'IFRAME']);
|
|
|
|
/** Link targets worth writing out: web, mail and phone links. */
|
|
function shownHref(href: string | null): string | null {
|
|
if (!href) return null;
|
|
const trimmed = href.trim();
|
|
if (/^mailto:/i.test(trimmed)) return trimmed.slice(7).split('?')[0] || null;
|
|
if (/^(https?:|tel:)/i.test(trimmed)) return trimmed;
|
|
return null;
|
|
}
|
|
|
|
function normalize(text: string): string {
|
|
return text.replace(/[ \t\f\v\r\n\u00a0]+/g, ' ');
|
|
}
|
|
|
|
/** Same address once with and once without the scheme or a trailing slash. */
|
|
function sameTarget(text: string, href: string): boolean {
|
|
const strip = (s: string) => s.trim().toLowerCase().replace(/^(https?:\/\/|mailto:|tel:)/, '').replace(/\/$/, '');
|
|
return strip(text) === strip(href);
|
|
}
|
|
|
|
export function htmlToReadableText(html: string): string {
|
|
if (!html) return '';
|
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
const out: string[] = [];
|
|
// Pending line breaks, so runs of empty blocks collapse to one blank line.
|
|
let pendingBreaks = 0;
|
|
let atLineStart = true;
|
|
|
|
const breakLine = (count: number) => {
|
|
if (out.length === 0) return;
|
|
pendingBreaks = Math.max(pendingBreaks, count);
|
|
};
|
|
const write = (text: string) => {
|
|
if (!text) return;
|
|
if (pendingBreaks > 0) {
|
|
out.push('\n'.repeat(pendingBreaks));
|
|
pendingBreaks = 0;
|
|
atLineStart = true;
|
|
}
|
|
if (atLineStart) text = text.replace(/^ +/, '');
|
|
if (!text) return;
|
|
out.push(text);
|
|
atLineStart = text.endsWith('\n');
|
|
};
|
|
|
|
const walk = (node: Node, pre: boolean, listDepth: number) => {
|
|
if (node.nodeType === 3) {
|
|
const value = node.nodeValue ?? '';
|
|
write(pre ? value : normalize(value));
|
|
return;
|
|
}
|
|
if (node.nodeType !== 1) return;
|
|
const el = node as Element;
|
|
const tag = el.tagName.toUpperCase();
|
|
if (SKIP.has(tag)) return;
|
|
if ((el as HTMLElement).hidden || /display\s*:\s*none/i.test(el.getAttribute('style') ?? '')) return;
|
|
|
|
if (tag === 'BR') {
|
|
out.push('\n');
|
|
pendingBreaks = 0;
|
|
atLineStart = true;
|
|
return;
|
|
}
|
|
if (tag === 'IMG' || (tag === 'INPUT' && el.getAttribute('type') === 'image')) {
|
|
const alt = normalize(el.getAttribute('alt') ?? '').trim();
|
|
if (alt) write(`[${alt}]`);
|
|
return;
|
|
}
|
|
if (tag === 'HR') {
|
|
breakLine(2);
|
|
write('---');
|
|
breakLine(2);
|
|
return;
|
|
}
|
|
|
|
const isBlock = BLOCK.has(tag);
|
|
const gap = PARAGRAPH.has(tag) && !(tag === 'UL' || tag === 'OL') ? 2 : 1;
|
|
if (isBlock) breakLine(listDepth > 0 && (tag === 'UL' || tag === 'OL') ? 1 : gap);
|
|
if (tag === 'LI') write(`${' '.repeat(Math.max(0, listDepth - 1))}• `);
|
|
if (tag === 'TD' || tag === 'TH') {
|
|
if (!atLineStart && pendingBreaks === 0) write(' ');
|
|
}
|
|
if (tag === 'BLOCKQUOTE') write('> ');
|
|
|
|
const childPre = pre || tag === 'PRE';
|
|
const childDepth = tag === 'UL' || tag === 'OL' ? listDepth + 1 : listDepth;
|
|
const start = out.length;
|
|
el.childNodes.forEach((child) => walk(child, childPre, childDepth));
|
|
|
|
if (tag === 'A') {
|
|
const href = shownHref(el.getAttribute('href'));
|
|
const text = out.slice(start).join('').trim();
|
|
if (href && !sameTarget(text, href)) write(text ? ` (${href})` : href);
|
|
}
|
|
if (isBlock) breakLine(tag === 'LI' ? 1 : gap);
|
|
};
|
|
|
|
walk(doc.body, false, 0);
|
|
return out
|
|
.join('')
|
|
.split('\n')
|
|
.map((line) => line.replace(/[ \t]+$/g, ''))
|
|
.join('\n')
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.trim();
|
|
}
|