-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
src/mumble/TextMessage.cpp: Implemented Markdown support for TextMess…
…ages Supported are sections, inline code and code-blocks, bold, italic and strikethrough text, links and quotes. It is also possible to use the backslash character to escape other characters (to prevent them from triggering Markdown interpretation). The changes made in this commit do not apply to direct messages though (which are assembled via a RichTextEditor and I haven't found a clever way to hack into that one yet). This implements #2945 and fixes #4075
- Loading branch information
Showing
6 changed files
with
381 additions
and
41 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,355 @@ | ||
// Copyright 2020 The Mumble Developers. All rights reserved. | ||
// Use of this source code is governed by a BSD-style license | ||
// that can be found in the LICENSE file at the root of the | ||
// Mumble source tree or at <https://www.mumble.info/LICENSE>. | ||
|
||
#include "Markdown.h" | ||
|
||
#include <QRegularExpression> | ||
#include <QRegularExpressionMatch> | ||
|
||
namespace Markdown { | ||
// Placeholder constant | ||
const QLatin1String regularLineBreakPlaceholder("%<\\!!linebreak!!//>@"); | ||
|
||
/// Tries to match and replace an escaped character at exactly the given offset in the string | ||
/// | ||
/// @param str A reference to the String to work on | ||
/// @param offset The offset at which the matching shall be done. This will be modified to point right after | ||
/// replacement text, if such a replacement has been made. | ||
/// @returns Whether a replacement has been made | ||
bool processEscapedChar(QString &str, int &offset) { | ||
static const QRegularExpression s_regex(QLatin1String("\\\\(.)")); | ||
|
||
QRegularExpressionMatch match = s_regex.match(str, offset, QRegularExpression::NormalMatch, QRegularExpression::AnchoredMatchOption); | ||
|
||
if (match.hasMatch()) { | ||
QString replacement = QString::fromLatin1("%1").arg(match.captured(1).toHtmlEscaped()); | ||
|
||
str.replace(match.capturedStart(), match.capturedEnd() - match.capturedStart(), replacement); | ||
|
||
offset += replacement.size(); | ||
|
||
return true; | ||
} | ||
|
||
return false; | ||
} | ||
|
||
/// Tries to match and replace a markdown section header at exactly the given offset in the string | ||
/// | ||
/// @param str A reference to the String to work on | ||
/// @param offset The offset at which the matching shall be done. This will be modified to point right after | ||
/// replacement text, if such a replacement has been made. | ||
/// @returns Whether a replacement has been made | ||
bool processMarkdownHeader(QString &str, int &offset) { | ||
// Match a markdown section heading. Also eat up a potential following newline in order to | ||
// not create a huge spacing after the heading | ||
static const QRegularExpression s_regex(QLatin1String("^(#+) (.*)"), QRegularExpression::MultilineOption); | ||
|
||
QRegularExpressionMatch match = s_regex.match(str, offset, QRegularExpression::NormalMatch, QRegularExpression::AnchoredMatchOption); | ||
|
||
if (match.hasMatch()) { | ||
int sectionLevel = match.captured(1).size(); | ||
QString sectionName = match.captured(2); | ||
|
||
QString replacement = QString::fromLatin1("<h%1>%2</h%1>").arg(sectionLevel).arg(sectionName.toHtmlEscaped()); | ||
|
||
str.replace(match.capturedStart(), match.capturedEnd() - match.capturedStart(), replacement); | ||
|
||
offset += replacement.size(); | ||
|
||
return true; | ||
} | ||
|
||
return false; | ||
} | ||
|
||
/// Tries to match and replace a markdown link at exactly the given offset in the string | ||
/// | ||
/// @param str A reference to the String to work on | ||
/// @param offset The offset at which the matching shall be done. This will be modified to point right after | ||
/// replacement text, if such a replacement has been made. | ||
/// @returns Whether a replacement has been made | ||
bool processMarkdownLink(QString &str, int &offset) { | ||
// Link in format [link text](url) | ||
static const QRegularExpression s_regex(QLatin1String("\\[([^\\]\\[]+)\\]\\(([^\\)]+)\\)")); | ||
|
||
QRegularExpressionMatch match = s_regex.match(str, offset, QRegularExpression::NormalMatch, QRegularExpression::AnchoredMatchOption); | ||
|
||
if (match.hasMatch()) { | ||
QString url = match.captured(2); | ||
|
||
if (!url.startsWith(QLatin1String("http"), Qt::CaseInsensitive)) { | ||
// For a markdown link to work, it has to start with the protocol specification, e.g. http or https | ||
// As we can't know for sure that the given website supports https, we'll have to fall back to http | ||
// Most browsers will upgrade the request to https whenver possible anyways though, so this shouldn't be | ||
// too much of a problem. | ||
url = QLatin1String("http://") + url; | ||
} | ||
|
||
QString replacement = QString::fromLatin1("<a href=\"%1\">%2</a>").arg(url).arg(match.captured(1).toHtmlEscaped()); | ||
str.replace(match.capturedStart(), match.capturedEnd() - match.capturedStart(), replacement); | ||
|
||
offset += replacement.size(); | ||
|
||
return true; | ||
} | ||
|
||
return false; | ||
} | ||
|
||
/// Tries to match and replace a markdown bold-text at exactly the given offset in the string | ||
/// | ||
/// @param str A reference to the String to work on | ||
/// @param offset The offset at which the matching shall be done. This will be modified to point right after | ||
/// replacement text, if such a replacement has been made. | ||
/// @returns Whether a replacement has been made | ||
bool processMarkdownBold(QString &str, int &offset) { | ||
// Bold text is marked as **bold** | ||
static const QRegularExpression s_regex(QLatin1String("\\*\\*([^*]+)\\*\\*")); | ||
|
||
QRegularExpressionMatch match = s_regex.match(str, offset, QRegularExpression::NormalMatch, QRegularExpression::AnchoredMatchOption); | ||
|
||
if (match.hasMatch()) { | ||
QString replacement = QString::fromLatin1("<b>%1</b>").arg(match.captured(1).toHtmlEscaped()); | ||
str.replace(match.capturedStart(), match.capturedEnd() - match.capturedStart(), replacement); | ||
|
||
offset += replacement.size(); | ||
|
||
return true; | ||
} | ||
|
||
return false; | ||
} | ||
|
||
/// Tries to match and replace a markdown italic-text at exactly the given offset in the string | ||
/// | ||
/// @param str A reference to the String to work on | ||
/// @param offset The offset at which the matching shall be done. This will be modified to point right after | ||
/// replacement text, if such a replacement has been made. | ||
/// @returns Whether a replacement has been made | ||
bool processMarkdownItalic(QString &str, int &offset) { | ||
// Italic text is marked as *italic* | ||
static const QRegularExpression s_regex(QLatin1String("\\*([^*]+)\\*")); | ||
|
||
QRegularExpressionMatch match = s_regex.match(str, offset, QRegularExpression::NormalMatch, QRegularExpression::AnchoredMatchOption); | ||
|
||
if (match.hasMatch()) { | ||
QString replacement = QString::fromLatin1("<i>%1</i>").arg(match.captured(1).toHtmlEscaped()); | ||
str.replace(match.capturedStart(), match.capturedEnd() - match.capturedStart(), replacement); | ||
|
||
offset += replacement.size(); | ||
|
||
return true; | ||
} | ||
|
||
return false; | ||
} | ||
|
||
/// Tries to match and replace a markdown strikethrough-text at exactly the given offset in the string | ||
/// | ||
/// @param str A reference to the String to work on | ||
/// @param offset The offset at which the matching shall be done. This will be modified to point right after | ||
/// replacement text, if such a replacement has been made. | ||
/// @returns Whether a replacement has been made | ||
bool processMarkdownStrikethrough(QString &str, int &offset) { | ||
// Strikethrough text is marked as ~~text~~ | ||
static const QRegularExpression s_regex(QLatin1String("~~([^~]+)~~")); | ||
|
||
QRegularExpressionMatch match = s_regex.match(str, offset, QRegularExpression::NormalMatch, QRegularExpression::AnchoredMatchOption); | ||
|
||
if (match.hasMatch()) { | ||
QString replacement = QString::fromLatin1("<s>%1</s>").arg(match.captured(1).toHtmlEscaped()); | ||
str.replace(match.capturedStart(), match.capturedEnd() - match.capturedStart(), replacement); | ||
|
||
offset += replacement.size(); | ||
|
||
return true; | ||
} | ||
|
||
return false; | ||
} | ||
|
||
/// Tries to match and replace a markdown quote (blockquote) at exactly the given offset in the string | ||
/// | ||
/// @param str A reference to the String to work on | ||
/// @param offset The offset at which the matching shall be done. This will be modified to point right after | ||
/// replacement text, if such a replacement has been made. | ||
/// @returns Whether a replacement has been made | ||
bool processMarkdownBlockQuote(QString &str, int &offset) { | ||
// Block quotes are (consecutive) lines starting with "> " | ||
static const QRegularExpression s_regex(QLatin1String("^(>|>) (.|\\n(>|>) )+"), QRegularExpression::MultilineOption); | ||
|
||
QRegularExpressionMatch match = s_regex.match(str, offset, QRegularExpression::NormalMatch, QRegularExpression::AnchoredMatchOption); | ||
|
||
if (match.hasMatch()) { | ||
QString quote = match.captured(0).replace(QLatin1String(">"), QLatin1String(">")); | ||
|
||
QStringList lines = quote.split(QChar::fromLatin1('\n')); | ||
|
||
quote.clear(); | ||
for (int i = 0; i < lines.size(); i++) { | ||
// remove the leading "> " | ||
quote += lines[i].right(lines[i].size() - 2); | ||
|
||
if (i != lines.size() - 1) { | ||
// Add linebreak back in | ||
quote += QString::fromLatin1("\n"); | ||
} | ||
} | ||
|
||
QString replacement = QString::fromLatin1("<div><i>%1</i></div>").arg(quote.toHtmlEscaped().replace(QLatin1String("\n"), QLatin1String("<br/>"))); | ||
str.replace(match.capturedStart(), match.capturedEnd() - match.capturedStart(), replacement); | ||
|
||
offset += replacement.size(); | ||
|
||
return true; | ||
} | ||
|
||
return false; | ||
} | ||
|
||
/// Tries to match and replace a markdown inline code snippet at exactly the given offset in the string | ||
/// | ||
/// @param str A reference to the String to work on | ||
/// @param offset The offset at which the matching shall be done. This will be modified to point right after | ||
/// replacement text, if such a replacement has been made. | ||
/// @returns Whether a replacement has been made | ||
bool processMarkdownInlineCode(QString &str, int &offset) { | ||
// Inline code fragments are marked as `code` | ||
static const QRegularExpression s_regex(QLatin1String("`([^`\n]+)`")); | ||
|
||
QRegularExpressionMatch match = s_regex.match(str, offset, QRegularExpression::NormalMatch, QRegularExpression::AnchoredMatchOption); | ||
|
||
if (match.hasMatch()) { | ||
QString replacement = QString::fromLatin1("<code>%1</code>").arg(match.captured(1).toHtmlEscaped()); | ||
str.replace(match.capturedStart(), match.capturedEnd() - match.capturedStart(), replacement); | ||
|
||
offset += replacement.size(); | ||
|
||
return true; | ||
} | ||
|
||
return false; | ||
} | ||
|
||
/// Tries to match and replace a markdown code block at exactly the given offset in the string | ||
/// | ||
/// @param str A reference to the String to work on | ||
/// @param offset The offset at which the matching shall be done. This will be modified to point right after | ||
/// replacement text, if such a replacement has been made. | ||
/// @returns Whether a replacement has been made | ||
bool processMarkdownCodeBlock(QString &str, int &offset) { | ||
// Code blocks are marked as ```code``` | ||
// Also consume a potential following newline as the <pre> tag will cause a linebreak anyways | ||
static const QRegularExpression s_regex(QLatin1String("```.*\\n([^`]+)```(\\r\\n|\\n|\\r)?")); | ||
|
||
QRegularExpressionMatch match = s_regex.match(str, offset, QRegularExpression::NormalMatch, QRegularExpression::AnchoredMatchOption); | ||
|
||
if (match.hasMatch()) { | ||
QString code = match.captured(1).toHtmlEscaped(); | ||
|
||
// Trim away leading linebreaks | ||
while (code.size() >= 1 && (code[0] == QLatin1Char('\n') || code[0] == QLatin1Char('\r'))) { | ||
code = code.right(code.size() - 1); | ||
} | ||
// Trim end of string | ||
while (code.size() >= 1 && code[code.size() - 1].isSpace()) { | ||
code = code.left(code.size() - 1); | ||
} | ||
|
||
if (code.isEmpty()) { | ||
return false; | ||
} | ||
|
||
// Replace linebreaks with a special placeholder as the linebreaks in a <pre> block must not be replaced | ||
// with <br/> | ||
QString replacement = QString::fromLatin1("<pre>%1</pre>").arg(code.replace(QLatin1String("\n"), regularLineBreakPlaceholder)); | ||
str.replace(match.capturedStart(), match.capturedEnd() - match.capturedStart(), replacement); | ||
|
||
offset += replacement.size(); | ||
|
||
return true; | ||
} | ||
|
||
return false; | ||
} | ||
|
||
/// Tries to match and replace a plain link at exactly the given offset in the string | ||
/// | ||
/// @param str A reference to the String to work on | ||
/// @param offset The offset at which the matching shall be done. This will be modified to point right after | ||
/// replacement text, if such a replacement has been made. | ||
/// @returns Whether a replacement has been made | ||
bool processPlainLink(QString &str, int &offset) { | ||
// We support links with prefixed protocol (e.g. https://bla.com) and prefixed with www (e.g. www.bla.com) | ||
static const QRegularExpression s_regex(QLatin1String("([a-zA-Z]+://|[wW][wW][wW]\\.)[^ \\t\\n<]+")); | ||
|
||
QRegularExpressionMatch match = s_regex.match(str, offset, QRegularExpression::NormalMatch, QRegularExpression::AnchoredMatchOption); | ||
|
||
if (match.hasMatch()) { | ||
QString url = match.captured(0); | ||
QString urlText = url.toHtmlEscaped(); | ||
|
||
if (url.startsWith(QLatin1String("www"), Qt::CaseInsensitive)) { | ||
// Link is missing a protocol specification. | ||
// Use http as the default | ||
url = QLatin1String("http://") + url; | ||
} | ||
|
||
QString replacement = QString::fromLatin1("<a href=\"%1\">%2</a>").arg(url).arg(urlText); | ||
str.replace(match.capturedStart(), match.capturedEnd() - match.capturedStart(), replacement); | ||
|
||
offset += replacement.size(); | ||
|
||
return true; | ||
} | ||
|
||
return false; | ||
} | ||
|
||
QString markdownToHTML(const QString &markdownInput) { | ||
QString htmlString = markdownInput; | ||
int offset = 0; | ||
|
||
while (offset < htmlString.size()) { | ||
// The trick here is to know that in a condition the or-branches are only | ||
// processed until the first expression returns true. At this point no | ||
// lower or-branch will be executed. This results in each of these functions | ||
// being called in succession until the first returns true (meaning that it | ||
// was able to recognize and replace a pattern). | ||
// Each function will only try to match its pattern at the exact offset given. | ||
// If a function was able to match and replace, it'll update the offset by | ||
// itself in order for the processing to start over right after the replacement | ||
// text (avoiding replacing parts of the replacement text which will probably | ||
// render the initial replacement invalid). | ||
// If no function matches, we increase the offset manually. | ||
// Do this until the end of the text has been reached. | ||
if (!( | ||
processMarkdownHeader(htmlString, offset) | ||
|| processMarkdownLink(htmlString, offset) | ||
|| processMarkdownBold(htmlString, offset) | ||
|| processMarkdownItalic(htmlString, offset) | ||
|| processMarkdownStrikethrough(htmlString, offset) | ||
|| processMarkdownBlockQuote(htmlString, offset) | ||
|| processMarkdownCodeBlock(htmlString, offset) | ||
|| processMarkdownInlineCode(htmlString, offset) | ||
|| processPlainLink(htmlString, offset) | ||
|| processEscapedChar(htmlString, offset) | ||
)) { | ||
offset++; | ||
} | ||
} | ||
|
||
// Replace linebreaks afterwards in order to not mess up the RegEx used by the | ||
// different functions. | ||
static const QRegularExpression s_lineBreakRegEx(QLatin1String("\r\n|\n|\r")); | ||
htmlString.replace(s_lineBreakRegEx, QLatin1String("</br>")); | ||
|
||
// Resore linebreaks in <pre> blocks | ||
htmlString.replace(regularLineBreakPlaceholder, QLatin1String("\n")); | ||
|
||
return htmlString; | ||
} | ||
}; // namespace Markdown |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
// Copyright 2020 The Mumble Developers. All rights reserved. | ||
// Use of this source code is governed by a BSD-style license | ||
// that can be found in the LICENSE file at the root of the | ||
// Mumble source tree or at <https://www.mumble.info/LICENSE>. | ||
|
||
#ifndef MUMBLE_MUMBLE_MARKDOWN_H_ | ||
#define MUMBLE_MUMBLE_MARKDOWN_H_ | ||
|
||
#include <QString> | ||
|
||
namespace Markdown { | ||
/// Converts the given piece of text, interprets it as markdown and replaces | ||
/// the markdown constructs with the respective HTML ones. | ||
/// | ||
/// @param markdownInput A reference to the input string | ||
/// @returns The processed HTML string | ||
QString markdownToHTML(const QString &markdownInput); | ||
}; // namespace Markdown | ||
|
||
#endif // MUMBLE_MUMBLE_MARKDOWN_H_ |
Oops, something went wrong.