diff --git a/src/mumble/MainWindow.cpp b/src/mumble/MainWindow.cpp index 65ef999b4..7b9cf3f90 100644 --- a/src/mumble/MainWindow.cpp +++ b/src/mumble/MainWindow.cpp @@ -50,6 +50,7 @@ #include "Utils.h" #include "ListenerLocalVolumeDialog.h" #include "ChannelListener.h" +#include "Markdown.h" #ifdef Q_OS_WIN # include "TaskList.h" @@ -1905,7 +1906,7 @@ void MainWindow::sendChatbarMessage(QString qsText) { Channel *c = pmModel->getChannel(qtvUsers->currentIndex()); qsText = qsText.toHtmlEscaped(); - qsText = TextMessage::autoFormat(qsText); + qsText = Markdown::markdownToHTML(qsText); if (!g.s.bChatBarUseSelection || p == NULL || p->uiSession == g.uiSession) { // Channel message diff --git a/src/mumble/Markdown.cpp b/src/mumble/Markdown.cpp new file mode 100644 index 000000000..050f19b8a --- /dev/null +++ b/src/mumble/Markdown.cpp @@ -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 . + +#include "Markdown.h" + +#include +#include + +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("%2").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("%2").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("%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 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("%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 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("%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 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("
%1
").arg(quote.toHtmlEscaped().replace(QLatin1String("\n"), QLatin1String("
"))); + 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("%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 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
 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 
 block must not be replaced
+			// with 
+ QString replacement = QString::fromLatin1("
%1
").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("%2").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("
")); + + // Resore linebreaks in
 blocks
+		htmlString.replace(regularLineBreakPlaceholder, QLatin1String("\n"));
+
+		return htmlString;
+	}
+}; // namespace Markdown
diff --git a/src/mumble/Markdown.h b/src/mumble/Markdown.h
new file mode 100644
index 000000000..033d796d3
--- /dev/null
+++ b/src/mumble/Markdown.h
@@ -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 .
+
+#ifndef MUMBLE_MUMBLE_MARKDOWN_H_
+#define MUMBLE_MUMBLE_MARKDOWN_H_
+
+#include 
+
+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_
diff --git a/src/mumble/TextMessage.cpp b/src/mumble/TextMessage.cpp
index 29ac4bd22..2f9f79506 100644
--- a/src/mumble/TextMessage.cpp
+++ b/src/mumble/TextMessage.cpp
@@ -19,43 +19,6 @@ void TextMessage::on_qcbTreeMessage_stateChanged(int s) {
 	bTreeMessage = s == Qt::Checked ? true : false;
 }
 
-QString TextMessage::autoFormat(QString qsPlain) {
-	QRegExp qr;
-	qr.setMinimal(true);
-	qr.setPatternSyntax(QRegExp::RegExp2);
-	qr.setCaseSensitivity(Qt::CaseInsensitive);
-
-	qr.setPattern(QLatin1String("[\\r\\n]+"));
-	qsPlain.replace(qr, QLatin1String("
")); - - qr.setPattern(QLatin1String("\\*(\\w+)\\*")); - qsPlain.replace(qr, QLatin1String("\\1")); - - qr.setPattern(QLatin1String("\"([^\"]+)\"")); - qsPlain.replace(qr, QLatin1String("\"\\1\"")); - - qr.setPattern(QLatin1String("[a-z-]+://[^ <]*")); - qr.setMinimal(false); - - int idx = 0; - do { - idx = qr.indexIn(qsPlain, idx); - if (idx >= 0) { - QString url = qr.capturedTexts().at(0); - QUrl u(url); - if (u.isValid()) { - int len = qr.matchedLength(); - QString replacement = QString::fromLatin1("%1").arg(url); - qsPlain.replace(idx, len, replacement); - idx += replacement.length(); - } else { - idx++; - } - } - } while (idx >= 0); - return qsPlain; -} - QString TextMessage::message() { return rteMessage->text(); } diff --git a/src/mumble/TextMessage.h b/src/mumble/TextMessage.h index 2660d978a..810213f88 100644 --- a/src/mumble/TextMessage.h +++ b/src/mumble/TextMessage.h @@ -19,7 +19,6 @@ class TextMessage : public QDialog, public Ui::TextMessage { public: TextMessage(QWidget *parent = NULL, QString title = tr("Enter text"), bool bChannel = false); QString message(); - static QString autoFormat(QString qsPlain); bool bTreeMessage; }; diff --git a/src/mumble/mumble.pro b/src/mumble/mumble.pro index 17e6e7c53..e7cbc02a1 100644 --- a/src/mumble/mumble.pro +++ b/src/mumble/mumble.pro @@ -143,7 +143,8 @@ HEADERS *= BanEditor.h \ PathListWidget.h \ XMLTools.h \ Screen.h \ - SvgIcon.h + SvgIcon.h \ + Markdown.h SOURCES *= BanEditor.cpp \ ACLEditor.cpp \ @@ -214,7 +215,8 @@ SOURCES *= BanEditor.cpp \ PathListWidget.cpp \ XMLTools.cpp \ Screen.cpp \ - SvgIcon.cpp + SvgIcon.cpp \ + Markdown.cpp CONFIG(qtspeech) { SOURCES *= TextToSpeech.cpp