diff options
Diffstat (limited to 'src/htmlbuilder.cpp')
| -rw-r--r-- | src/htmlbuilder.cpp | 219 |
1 files changed, 219 insertions, 0 deletions
diff --git a/src/htmlbuilder.cpp b/src/htmlbuilder.cpp new file mode 100644 index 0000000..1830383 --- /dev/null +++ b/src/htmlbuilder.cpp @@ -0,0 +1,219 @@ +#include "htmlbuilder.h" + +#include <QRegularExpression> + +namespace { + +const char *kStyle = R"CSS( +body { font-family: sans-serif; font-size: 10pt; margin: 12px; } +pre.plain { white-space: pre-wrap; word-wrap: break-word; + font-family: monospace; margin: 0; } +span.quote { color: #4a6f8a; } +.message { border-top: 1px solid #bbb; padding: 10px 0; } +.message:first-child { border-top: none; } +.msg-header { font-size: 9pt; color: #555; margin-bottom: 8px; } +.msg-header .who { font-weight: bold; color: #000; } +.stub { font-size: 9pt; color: #666; padding: 4px 0; + border-top: 1px solid #ddd; } +)CSS"; + +} // namespace + +QString HtmlBuilder::renderPlain(const QString &text) +{ + QString out; + out += QStringLiteral("<pre class=\"plain\">"); + + const QStringList lines = text.split(QLatin1Char('\n')); + for (int i = 0; i < lines.size(); ++i) { + const QString &line = lines.at(i); + const bool quoted = line.startsWith(QLatin1Char('>')); + + if (quoted) + out += QStringLiteral("<span class=\"quote\">"); + out += line.toHtmlEscaped(); + if (quoted) + out += QStringLiteral("</span>"); + + if (i + 1 < lines.size()) + out += QLatin1Char('\n'); + } + + out += QStringLiteral("</pre>"); + return out; +} + +QString HtmlBuilder::document(const QString &bodyHtml) +{ + return QStringLiteral( + "<!DOCTYPE html><html><head><meta charset=\"utf-8\">" + "<style>%1</style></head><body>%2</body></html>") + .arg(QString::fromUtf8(kStyle), bodyHtml); +} + +QString HtmlBuilder::namespaceCids(const QString &html, const QString &prefix) +{ + if (prefix.isEmpty()) + return html; + + // This runs on the sender's raw, unescaped HTML markup (not on text that + // has been through toHtmlEscaped()), so no double-escaping happens here; + // it is purely a URL rewrite over the existing markup. + // + // Two independent patterns are needed: + // + // 1. Attribute values: src=, href=, background=, poster= (the common + // real-world attributes that can carry a cid: reference in HTML + // email), quoted with " or ', quoted the other way, or entirely + // unquoted (<img src=cid:x> is valid HTML and unquoted references are + // seen in the wild). Attribute name is matched case-insensitively and + // whitespace/newlines are tolerated around '='. + // + // 2. CSS url(cid:...): appears both inside a style="" attribute value + // and inside a <style> block, quoted or unquoted. This is handled as + // a separate pass since it has different quoting/terminator rules + // (a bare url(...) form terminates on ')', not on a matching quote). + // + // srcset= is not handled: it is a list of URL/descriptor pairs with a + // different quoting grammar, and cid: URLs in srcset are not something + // real-world mail has been observed to use; treating it is out of scope + // for this task. + QString out = html; + + { + // (?:"([^"]*)"|'([^']*)'|([^\s"'<>]+)) picks the correctly-bounded + // value for whichever quoting style is used, so a quoted value can + // never be captured past its own closing quote (fixing the greedy + // [^"']+ that the naive version used, which is provably safe here + // since each alternative's character class already excludes its own + // terminator). capturedStart(N) (rather than testing the captured + // text for emptiness) is what disambiguates which alternative + // matched, so an empty-but-present cid ("cid:\"\"") is handled + // correctly too. + static const QRegularExpression re( + QStringLiteral( + "\\b(src|href|background|poster)" + "\\s*=\\s*" + "(?:\"cid:([^\"]*)\"|'cid:([^']*)'|cid:([^\\s\"'<>]+))"), + QRegularExpression::CaseInsensitiveOption); + + QString rewritten; + qsizetype last = 0; + auto it = re.globalMatch(out); + while (it.hasNext()) { + const QRegularExpressionMatch m = it.next(); + QString id; + QChar quote; + if (m.capturedStart(2) != -1) { + id = m.captured(2); + quote = QLatin1Char('"'); + } else if (m.capturedStart(3) != -1) { + id = m.captured(3); + quote = QLatin1Char('\''); + } else { + id = m.captured(4); + } + + rewritten += out.mid(last, m.capturedStart() - last); + rewritten += m.captured(1); + rewritten += QStringLiteral("="); + if (!quote.isNull()) + rewritten += quote; + rewritten += QStringLiteral("cid:%1!%2").arg(prefix, id); + if (!quote.isNull()) + rewritten += quote; + last = m.capturedEnd(); + } + rewritten += out.mid(last); + out = rewritten; + } + + { + // CSS url(cid:...), quoted (' or ") or bare, inside a style="" value + // or a <style> block. Bare form terminates at ')'. + static const QRegularExpression re( + QStringLiteral( + "url\\(\\s*" + "(?:\"cid:([^\"]*)\"|'cid:([^']*)'|cid:([^)\\s]+))" + "\\s*\\)"), + QRegularExpression::CaseInsensitiveOption); + + QString rewritten; + qsizetype last = 0; + auto it = re.globalMatch(out); + while (it.hasNext()) { + const QRegularExpressionMatch m = it.next(); + QString id; + QChar quote; + if (m.capturedStart(1) != -1) { + id = m.captured(1); + quote = QLatin1Char('"'); + } else if (m.capturedStart(2) != -1) { + id = m.captured(2); + quote = QLatin1Char('\''); + } else { + id = m.captured(3); + } + + rewritten += out.mid(last, m.capturedStart() - last); + rewritten += QStringLiteral("url("); + if (!quote.isNull()) + rewritten += quote; + rewritten += QStringLiteral("cid:%1!%2").arg(prefix, id); + if (!quote.isNull()) + rewritten += quote; + rewritten += QStringLiteral(")"); + last = m.capturedEnd(); + } + rewritten += out.mid(last); + out = rewritten; + } + + return out; +} + +QString HtmlBuilder::renderBody(const ThreadRenderItem &item, Mode mode) +{ + if (mode == PreferHtml && item.message.hasHtml()) + return namespaceCids(item.message.htmlBody, item.cidPrefix); + return renderPlain(item.message.plainBody); +} + +QString HtmlBuilder::renderStub(const ParsedMessage &message) +{ + return QStringLiteral("<div class=\"stub\">%1 — %2</div>") + .arg(message.from.toHtmlEscaped(), message.subject.toHtmlEscaped()); +} + +QString HtmlBuilder::build(const ParsedMessage &message, Mode mode) +{ + ThreadRenderItem item; + item.message = message; + item.expanded = true; + return document(renderBody(item, mode)); +} + +QString HtmlBuilder::buildThread(const QList<ThreadRenderItem> &items, Mode mode) +{ + QString body; + + for (int i = 0; i < items.size(); ++i) { + const ThreadRenderItem &item = items.at(i); + + if (!item.expanded) { + body += renderStub(item.message); + continue; + } + + body += QStringLiteral( + "<div class=\"message\" id=\"msg-%1\">" + "<div class=\"msg-header\"><span class=\"who\">%2</span><br>%3</div>" + "%4</div>") + .arg(QString::number(i), + item.message.from.toHtmlEscaped(), + item.message.date.toHtmlEscaped(), + renderBody(item, mode)); + } + + return document(body); +} |
