// SPDX-FileCopyrightText: 2023 James Graham // SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL #include "texthandler.h" #include #include #include #include #include #include #include #include #include #include #include "messagecomponenttype.h" #include "models/customemojimodel.h" #include "utils.h" using namespace Qt::StringLiterals; static const QStringList allowedTags = { u"font"_s, u"del"_s, u"h1"_s, u"h2"_s, u"h3"_s, u"h4"_s, u"h5"_s, u"h6"_s, u"blockquote"_s, u"p"_s, u"a"_s, u"ul"_s, u"ol"_s, u"sup"_s, u"sub"_s, u"li"_s, u"b"_s, u"i"_s, u"u"_s, u"strong"_s, u"em"_s, u"strike"_s, u"code"_s, u"hr"_s, u"br"_s, u"div"_s, u"table"_s, u"thead"_s, u"tbody"_s, u"tr"_s, u"th"_s, u"td"_s, u"caption"_s, u"pre"_s, u"span"_s, u"img"_s, u"details"_s, u"summary"_s}; static const QHash allowedAttributes = {{u"font"_s, {u"data-mx-bg-color"_s, u"data-mx-color"_s, u"color"_s}}, {u"span"_s, {u"data-mx-bg-color"_s, u"data-mx-color"_s, u"data-mx-spoiler"_s}}, {u"a"_s, {u"name"_s, u"target"_s, u"href"_s}}, {u"img"_s, {u"style"_s, u"width"_s, u"height"_s, u"alt"_s, u"title"_s, u"src"_s}}, {u"ol"_s, {u"start"_s}}, {u"code"_s, {u"class"_s}}}; static const QStringList allowedLinkSchemes = {u"https"_s, u"http"_s, u"ftp"_s, u"mailto"_s, u"magnet"_s}; static const QStringList blockTags = {u"blockquote"_s, u"p"_s, u"ul"_s, u"ol"_s, u"div"_s, u"table"_s, u"pre"_s}; static const QString customEmojiStyle = u"vertical-align:bottom"_s; QString TextHandler::data() const { return m_data; } void TextHandler::setData(const QString &string) { m_data = string; m_pos = 0; } QString TextHandler::handleSendText() { m_pos = 0; m_dataBuffer = markdownToHTML(m_data); m_dataBuffer = customMarkdownToHtml(m_dataBuffer); m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType); // Strip any disallowed tags/attributes. QString outputString; while (m_pos < m_dataBuffer.length()) { next(); QString nextTokenBuffer = m_nextToken; switch (m_nextTokenType) { case Text: nextTokenBuffer = escapeHtml(nextTokenBuffer); nextTokenBuffer = CustomEmojiModel::instance().preprocessText(nextTokenBuffer); break; case TextCode: nextTokenBuffer = escapeHtml(nextTokenBuffer); break; case Tag: if (!isAllowedTag(getTagType(m_nextToken))) { nextTokenBuffer = QString(); } nextTokenBuffer = cleanAttributes(getTagType(m_nextToken), nextTokenBuffer); default: break; } outputString.append(nextTokenBuffer); m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType); } if (outputString.count("

"_L1) == 1 && outputString.count("

"_L1) == 1 && outputString.startsWith("

"_L1) && outputString.endsWith("

"_L1)) { outputString.remove("

"_L1); outputString.remove("

"_L1); } return outputString; } QString TextHandler::handleRecieveRichText(Qt::TextFormat inputFormat, const NeoChatRoom *room, const Quotient::RoomEvent *event, bool stripNewlines, bool isEdited, bool spoilerRevealed) { m_pos = 0; m_dataBuffer = m_data; // Strip mx-reply if present. m_dataBuffer.remove(TextRegex::removeRichReply); // For plain text, convert links, escape html and convert line brakes. if (inputFormat == Qt::PlainText) { m_dataBuffer = escapeHtml(m_dataBuffer); m_dataBuffer.replace(u'\n', u"
"_s); } // Linkify any plain text urls m_dataBuffer = linkifyUrls(m_dataBuffer); // Apply user style m_dataBuffer.replace(TextRegex::userPill, uR"(\1)"_s); // Make all media URLs resolvable. if (room && event) { QRegularExpressionMatchIterator i = TextRegex::mxcImage.globalMatch(m_dataBuffer); while (i.hasNext()) { const QRegularExpressionMatch match = i.next(); const QUrl mediaUrl = room->makeMediaUrl(event->id(), QUrl(u"mxc://"_s + match.captured(2) + u'/' + match.captured(3))); QStringList extraAttributes = match.captured(4).split(QChar::Space); const bool isEmoticon = match.captured(1).contains(u"data-mx-emoticon"_s); // If the image does not have an explicit width, but has a vertical-align it's most likely an emoticon. // We must do some pre-processing for it to show up nicely in and around text. if (isEmoticon) { // Align it properly extraAttributes.append(u"style=\"%1\""_s.arg(customEmojiStyle)); } m_dataBuffer.replace(match.captured(0), u"'); } } // Strip any disallowed tags/attributes. QString outputString; m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType); while (m_pos < m_dataBuffer.length()) { next(); QString nextTokenBuffer = m_nextToken; if (m_nextTokenType == Type::Text || m_nextTokenType == Type::TextCode) { nextTokenBuffer = escapeHtml(nextTokenBuffer); } else if (m_nextTokenType == Type::Tag) { if (!isAllowedTag(getTagType(m_nextToken))) { nextTokenBuffer = QString(); } else if ((getTagType(m_nextToken) == u"br"_s && stripNewlines)) { nextTokenBuffer = u' '; } nextTokenBuffer = cleanAttributes(getTagType(m_nextToken), nextTokenBuffer, true, spoilerRevealed); } outputString.append(nextTokenBuffer); m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType); } if (isEdited) { if (outputString.endsWith(u"

"_s)) { outputString.insert(outputString.length() - 4, editString()); } else if (outputString.endsWith(u""_s) || outputString.endsWith(u""_s) || outputString.endsWith(u""_s) || outputString.endsWith(u""_s) || outputString.endsWith(u""_s)) { outputString.append(u"

%1

"_s.arg(editString())); } else { outputString.append(editString()); } } /** * Replace with * Note: is still not a valid tag for the message from the server. We * convert as that is what is needed for Qt::RichText. */ outputString.replace(TextRegex::strikethrough, u"\\1"_s); if (outputString.count("

"_L1) == 1 && outputString.count("

"_L1) == 1 && outputString.startsWith("

"_L1) && outputString.endsWith("

"_L1)) { outputString.remove("

"_L1); outputString.remove("

"_L1); } return outputString; } QString TextHandler::handleRecievePlainText(Qt::TextFormat inputFormat, const bool &stripNewlines) { m_pos = 0; m_dataBuffer = m_data; // Strip mx-reply if present. m_dataBuffer.remove(TextRegex::removeRichReply); // Escaping then unescaping allows < and > to be maintained in a plain text string // otherwise markdownToHTML will strip what it thinks is a bad html tag entirely. if (inputFormat == Qt::PlainText) { m_dataBuffer = escapeHtml(m_dataBuffer); } /** * This seems counterproductive but by converting any markup which could * arrive (e.g. in a caption body) it can then be stripped by the same code. */ m_dataBuffer = markdownToHTML(m_dataBuffer); // This is how \n is converted and for plain text we need it to just be
// to prevent extra newlines being inserted. m_dataBuffer.replace(u"
\n"_s, u"
"_s); if (stripNewlines) { m_dataBuffer.replace(u"
\n"_s, u" "_s); m_dataBuffer.replace(u"
"_s, u" "_s); m_dataBuffer.replace(u"
\n"_s, u" "_s); m_dataBuffer.replace(u"
"_s, u" "_s); m_dataBuffer.replace(u'\n', u" "_s); m_dataBuffer.replace(u'\u2028', u" "_s); } // Strip all tags/attributes except code blocks which will be escaped. QString outputString; m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType); while (m_pos < m_dataBuffer.length()) { next(); QString nextTokenBuffer = m_nextToken; if (m_nextTokenType == Type::TextCode) { nextTokenBuffer = unescapeHtml(nextTokenBuffer); } else if (m_nextTokenType == Type::Tag) { if (getTagType(m_nextToken) == u"br"_s && !stripNewlines) { nextTokenBuffer = u'\n'; } else { nextTokenBuffer = QString(); } } outputString.append(nextTokenBuffer); m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType); } // Escaping then unescaping allows < and > to be maintained in a plain text string // otherwise markdownToHTML will strip what it thinks is a bad html tag entirely. outputString = unescapeHtml(outputString); outputString = outputString.trimmed(); return outputString; } void TextHandler::next() { QString searchStr; if (m_nextTokenType == Type::Tag) { searchStr = u'>'; } else if (m_nextTokenType == Type::TextCode) { // Anything between code tags is assumed to be plain text searchStr = u""_s; } else { searchStr = u'<'; } int tokenEnd = m_dataBuffer.indexOf(searchStr, m_pos + 1); if (tokenEnd == -1) { tokenEnd = m_dataBuffer.length(); } m_nextToken = m_dataBuffer.mid(m_pos, tokenEnd - m_pos + (m_nextTokenType == Type::Tag ? 1 : 0)); m_pos = tokenEnd + (m_nextTokenType == Type::Tag ? 1 : 0); } TextHandler::Type TextHandler::nextTokenType(const QString &string, int currentPos, const QString ¤tToken, Type currentTokenType) const { if (currentPos >= string.length()) { // This is to stop the function accessing an index outside the length of // string during the final loop. return Type::End; } else if (currentTokenType == Type::Tag && getTagType(currentToken) == u"code"_s && !isCloseTag(currentToken) && string.indexOf(u""_s, currentPos) != currentPos) { return Type::TextCode; } else if (string[currentPos] == u'<' && string[currentPos + 1] != u' ') { return Type::Tag; } else { return Type::Text; } } int TextHandler::nextBlockPos(const QString &string) { if (string.isEmpty()) { return -1; } const auto nextTokenType = this->nextTokenType(string, 0, {}, Text); // If there is no tag at the start we need to handle potentially having some // text with no

tag. if (nextTokenType == Text) { int pos = 0; while (pos < string.size()) { pos = string.indexOf(u'<', pos); if (pos == -1) { pos = string.size(); } else { const auto tagType = getTagType(string.mid(pos, string.indexOf(u'>', pos) - pos)); if (blockTags.contains(tagType)) { return pos; } } pos++; } return string.size(); } int tagEndPos = string.indexOf(u'>'); QString tag = string.first(tagEndPos + 1); QString tagType = getTagType(tag); // If the start tag is not a block tag there can be only 1 block. if (!blockTags.contains(tagType)) { return string.size(); } const auto closeTag = u""_s.arg(tagType); int closeTagPos = string.indexOf(closeTag); // If the close tag can't be found assume malformed html and process as single block. if (closeTagPos == -1) { return string.size(); } return closeTagPos + closeTag.size(); } MessageComponent TextHandler::nextBlock(const QString &string, int nextBlockPos, Qt::TextFormat inputFormat, const NeoChatRoom *room, const Quotient::RoomEvent *event, bool isEdited, bool spoilerRevealed) { if (string.isEmpty()) { return {}; } int tagEndPos = string.indexOf(u'>'); QString tag = string.first(tagEndPos + 1); QString tagType = getTagType(tag); const auto messageComponentType = MessageComponentType::typeForTag(tagType); QVariantMap attributes; if (messageComponentType == MessageComponentType::Code) { attributes = getAttributes(u"code"_s, string.mid(tagEndPos + 1, string.indexOf(u'>', tagEndPos + 1) - tagEndPos)); } auto content = stripBlockTags(string.first(nextBlockPos), tagType); setData(content); switch (messageComponentType) { case MessageComponentType::Code: content = unescapeHtml(content); break; default: content = handleRecieveRichText(inputFormat, room, event, false, isEdited, spoilerRevealed); } if (content.contains(u"data-mx-spoiler"_s)) { attributes[u"hasSpoiler"_s] = true; } return MessageComponent{messageComponentType, content, attributes}; } QString TextHandler::stripBlockTags(QString string, const QString &tagType) const { if (blockTags.contains(tagType) && tagType != u"ol"_s && tagType != u"ul"_s && tagType != u"table"_s && string.startsWith(u"<%1"_s.arg(tagType))) { string.remove(0, string.indexOf(u'>') + 1).remove(string.indexOf(u""_s.arg(tagType)), string.size()); } if (string.startsWith(u"\n"_s)) { string.remove(0, 1); } if (string.endsWith(u"\n"_s)) { string.remove(string.size() - 1, string.size()); } if (tagType == u"pre"_s) { if (string.startsWith(u"') + 1); string.remove(string.indexOf(u""_s), string.size()); } if (string.endsWith(u"\n"_s)) { string.remove(string.size() - 1, string.size()); } } if (tagType == u"blockquote"_s) { int startQuotationIndex = 0; int endQuotationIndex = string.size(); // We have to insert the quotation marks inside of the existing // paragraphs, otherwise we add unnecessary line breaks. if (string.startsWith(u"

"_s)) { startQuotationIndex = string.indexOf(u">") + 1; endQuotationIndex = string.lastIndexOf(u"

") + 1; } // This is not a normal quotation mark but U+201C string.insert(startQuotationIndex, u'“'); // This is U+201D string.insert(endQuotationIndex, u'”'); } return string; } QString TextHandler::getTagType(const QString &tagToken) const { if (tagToken.isEmpty() || tagToken.length() < 2) { return QString(); } const int tagTypeStart = tagToken[1] == u'/' ? 2 : 1; const int tagTypeEnd = tagToken.indexOf(TextRegex::endTagType, tagTypeStart); return tagToken.mid(tagTypeStart, tagTypeEnd - tagTypeStart); } bool TextHandler::isCloseTag(const QString &tagToken) const { if (tagToken.isEmpty()) { return false; } return tagToken[1] == u'/'; } QString TextHandler::getAttributeType(const QString &string) { if (!string.contains(u'=')) { return string; } const int equalsPos = string.indexOf(u'='); return string.left(equalsPos); } QString TextHandler::getAttributeData(const QString &string, bool stripQuotes) { if (!string.contains(u'=')) { return QString(); } const int equalsPos = string.indexOf(u'='); auto data = string.right(string.length() - equalsPos - 1); if (stripQuotes) { data = TextRegex::attributeData.match(data).captured(1); } return data; } bool TextHandler::isAllowedTag(const QString &type) { return allowedTags.contains(type); } bool TextHandler::isAllowedAttribute(const QString &tag, const QString &attribute) { return allowedAttributes[tag].contains(attribute); } bool TextHandler::isAllowedLink(const QString &link, bool isImg) { const QUrl linkUrl = QUrl(link); if (isImg) { return !linkUrl.isRelative() && linkUrl.scheme() == u"mxc"_s; } else { return !linkUrl.isRelative() && allowedLinkSchemes.contains(linkUrl.scheme()); } } QString TextHandler::cleanAttributes(const QString &tag, const QString &tagString, bool addStyle, bool spoilerRevealed) { if (!tagString.contains(u'<') || !tagString.contains(u'>')) { return tagString; } int nextAttributeIndex = tagString.indexOf(u' ', 1); if (nextAttributeIndex != -1) { QString outputString = tagString.left(nextAttributeIndex); QString nextAttribute; int nextSpaceIndex; nextAttributeIndex += 1; while (nextAttributeIndex < tagString.length()) { nextSpaceIndex = tagString.indexOf(TextRegex::endAttributeType, nextAttributeIndex); if (nextSpaceIndex == -1) { nextSpaceIndex = tagString.length(); } nextAttribute = tagString.mid(nextAttributeIndex, nextSpaceIndex - nextAttributeIndex); if (isAllowedAttribute(tag, getAttributeType(nextAttribute))) { QString style; if (tag == u"img"_s && getAttributeType(nextAttribute) == u"src"_s) { QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1); if (isAllowedLink(attributeData, true)) { outputString.append(u' ' + nextAttribute); } } else if (tag == u'a' && getAttributeType(nextAttribute) == u"href"_s) { QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1); if (isAllowedLink(attributeData)) { outputString.append(u' ' + nextAttribute); } } else if (tag == u"code"_s && getAttributeType(nextAttribute) == u"class"_s) { if (getAttributeData(nextAttribute).remove(u'"').startsWith(u"language-"_s)) { outputString.append(u' ' + nextAttribute); } } else if (tag == u"img"_s && getAttributeType(nextAttribute) == u"style"_s) { const QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1); // Ignore every other style attribute except for our own, which we use to align custom emoticons if (attributeData == customEmojiStyle) { outputString.append(u' ' + nextAttribute); } } else if (getAttributeType(nextAttribute) == u"data-mx-color"_s) { const QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1); style.append(u"color: "_s + attributeData + u';'); } else if (getAttributeType(nextAttribute) == u"data-mx-bg-color"_s) { const QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1); style.append(u"background-color: "_s + attributeData + u';'); } else { outputString.append(u' ' + nextAttribute); } if (!style.isEmpty()) { outputString.append(u" style=\""_s + style + u'"'); } } nextAttributeIndex = nextSpaceIndex + 1; } return addStyle ? this->addStyle(tag, outputString, spoilerRevealed) : outputString + u'>'; } return addStyle ? this->addStyle(tag, tagString) : tagString; } QString TextHandler::addStyle(const QString &tag, QString cleanTagString, bool spoilerRevealed) { if (cleanTagString.endsWith(u'>')) { cleanTagString.removeLast(); } if (!cleanTagString.startsWith(u"(qmlAttachedPropertiesObject(this, true)); cleanTagString += u" style=\"color: %1; background: %2;\""_s.arg(spoilerRevealed ? theme->highlightedTextColor().name() : u"transparent"_s, theme->alternateBackgroundColor().name()); } } return cleanTagString + u'>'; } QVariantMap TextHandler::getAttributes(const QString &tag, const QString &tagString) { QVariantMap attributes; int nextAttributeIndex = tagString.indexOf(u' ', 1); if (nextAttributeIndex != -1) { QString nextAttribute; int nextSpaceIndex; nextAttributeIndex += 1; while (nextAttributeIndex < tagString.length()) { nextSpaceIndex = tagString.indexOf(TextRegex::endAttributeType, nextAttributeIndex); if (nextSpaceIndex == -1) { nextSpaceIndex = tagString.length(); } nextAttribute = tagString.mid(nextAttributeIndex, nextSpaceIndex - nextAttributeIndex); if (isAllowedAttribute(tag, getAttributeType(nextAttribute))) { if (tag == u"img"_s && getAttributeType(nextAttribute) == u"src"_s) { QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1); if (isAllowedLink(attributeData, true)) { attributes[getAttributeType(nextAttribute)] = getAttributeData(nextAttribute, true); } } else if (tag == u'a' && getAttributeType(nextAttribute) == u"href"_s) { QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1); if (isAllowedLink(attributeData)) { attributes[getAttributeType(nextAttribute)] = getAttributeData(nextAttribute, true); } } else if (tag == u"code"_s && getAttributeType(nextAttribute) == u"class"_s) { if (getAttributeData(nextAttribute).remove(u'"').startsWith(u"language-"_s)) { attributes[getAttributeType(nextAttribute)] = convertCodeLanguageString(getAttributeData(nextAttribute, true)); } } else { attributes[getAttributeType(nextAttribute)] = getAttributeData(nextAttribute, true); } } nextAttributeIndex = nextSpaceIndex + 1; } } return attributes; } QList TextHandler::textComponents(QString string, Qt::TextFormat inputFormat, const NeoChatRoom *room, const Quotient::RoomEvent *event, bool isEdited, bool spoilerRevealed) { if (string.trimmed().isEmpty()) { return {MessageComponent{MessageComponentType::Text, i18n("This event does not have any content."), {}}}; } // Strip mx-reply if present. string.remove(TextRegex::removeRichReply); QList components; while (!string.isEmpty()) { const auto nextBlockPos = this->nextBlockPos(string); const auto nextBlock = this->nextBlock(string, nextBlockPos, inputFormat, room, event, nextBlockPos == string.size() ? isEdited : false, spoilerRevealed); components += nextBlock; string.remove(0, nextBlockPos); if (string.startsWith(u"\n"_s)) { string.remove(0, 1); } string = string.trimmed(); if (event != nullptr && room != nullptr) { if (auto e = eventCast(event); e && e->msgtype() == Quotient::MessageEventType::Emote && components.size() == 1) { if (components[0].type == MessageComponentType::Text) { components[0].display = emoteString(room, event) + components[0].display; } else { components.prepend(MessageComponent{MessageComponentType::Text, emoteString(room, event), {}}); } } } } if (isEdited && components.last().type != MessageComponentType::Text && components.last().type != MessageComponentType::Quote) { components += MessageComponent{MessageComponentType::Text, editString(), {}}; } return components; } QString TextHandler::markdownToHTML(const QString &markdown) { const auto str = markdown.toUtf8(); char *tmp_buf = cmark_markdown_to_html(str.constData(), str.size(), CMARK_OPT_HARDBREAKS | CMARK_OPT_UNSAFE); const std::string html(tmp_buf); free(tmp_buf); auto result = QString::fromStdString(html).trimmed(); result.replace(u""_s, QString()); return result; } /** * TODO: make this more intelligent currently other characters are not escaped * especially & as this can conflict with the cmark markdown to html conversion * which already escapes characters in code blocks. The < > still need to be handled * when the user manually types in the html. */ QString TextHandler::escapeHtml(QString stringIn) { stringIn.replace(u'<', u"<"_s); stringIn.replace(u'>', u">"_s); return stringIn; } QString TextHandler::unescapeHtml(QString stringIn) { // For those situations where brackets in code block get double escaped stringIn.replace(u"&lt;"_s, u"<"_s); stringIn.replace(u"&gt;"_s, u">"_s); stringIn.replace(u"<"_s, u"<"_s); stringIn.replace(u">"_s, u">"_s); stringIn.replace(u"&"_s, u"&"_s); stringIn.replace(u"""_s, u"\""_s); stringIn.replace(u"'"_s, u"'"_s); return stringIn; } QString TextHandler::linkifyUrls(QString stringIn) { QRegularExpressionMatch match; int start = 0; for (int index = 0; index != -1; index = stringIn.indexOf(TextRegex::mxId, start, &match)) { int skip = 0; if (match.captured(0).size() > 0) { if (stringIn.left(index).count(u""_s) == stringIn.left(index).count(u""_s)) { auto replacement = u"%1"_s.arg(match.captured(1)); stringIn = stringIn.replace(index, match.captured(0).size(), replacement); } else { skip = match.captured().length(); } } start = index + skip; match = {}; } start = 0; match = {}; for (int index = 0; index != -1; index = stringIn.indexOf(TextRegex::plainUrl, start, &match)) { int skip = 0; if (match.captured(0).size() > 0) { if (stringIn.left(index).count(u""_s) == stringIn.left(index).count(u""_s)) { auto replacement = u"%1"_s.arg(match.captured(1)); stringIn = stringIn.replace(index, match.captured(0).size(), replacement); skip = replacement.length(); } else { skip = match.captured().length(); } } start = index + skip; match = {}; } start = 0; match = {}; for (int index = 0; index != -1; index = stringIn.indexOf(TextRegex::emailAddress, start, &match)) { int skip = 0; if (match.captured(0).size() > 0) { if (stringIn.left(index).count(u""_s) == stringIn.left(index).count(u""_s)) { auto replacement = u"%1"_s.arg(match.captured(2)); stringIn = stringIn.replace(index, match.captured(0).size(), replacement); skip = replacement.length(); } else { skip = match.captured().length(); } } start = index + skip; match = {}; } return stringIn; } QString TextHandler::customMarkdownToHtml(const QString &stringIn) { QString buffer = stringIn; const auto processSyntax = [&buffer](const QString &syntax, const QString &beginTag, const QString &endTag) { qsizetype beginCodeBlockTag = buffer.indexOf(u""_s); qsizetype endCodeBlockTag = buffer.indexOf(u""_s, beginCodeBlockTag + 1); // Index to search from qsizetype lastPos = 0; while (true) { const qsizetype pos = buffer.indexOf(syntax, lastPos); if (pos == -1) { break; } // If we're inside a code block, ignore and move the search past this code block const bool validCodeBlock = beginCodeBlockTag != -1 && endCodeBlockTag != -1; if (validCodeBlock && pos > beginCodeBlockTag && pos < endCodeBlockTag) { lastPos = endCodeBlockTag + 7; // since we moved past this code block, make sure to update the indices for the next one beginCodeBlockTag = buffer.indexOf(u""_s, lastPos + 1); endCodeBlockTag = buffer.indexOf(u""_s, beginCodeBlockTag + 1); continue; } qsizetype nextPos = buffer.indexOf(syntax, pos + 1); if (nextPos == -1) { break; } // Replace the beginning syntax buffer.replace(pos, syntax.length(), beginTag); // Update positions and re-search since the underlying text buffer changed nextPos = buffer.indexOf(syntax, pos + 1); // Now replace the end syntax buffer.replace(nextPos, syntax.length(), endTag); // If we have begun checking spoilers past our current code block, make sure we're in the next one (if it exists) if (nextPos > endCodeBlockTag) { beginCodeBlockTag = buffer.indexOf(u""_s, nextPos + 1); endCodeBlockTag = buffer.indexOf(u""_s, beginCodeBlockTag + 1); } // Move the search pointer past this point. // Not technically needed in most cases since we replaced the original tag, but needed for code blocks // which still have the characters. lastPos = nextPos + syntax.length(); } }; // spoilers processSyntax(u"||"_s, u""_s, u""_s); // strikethrough processSyntax(u"~~"_s, u""_s, u""_s); return buffer; } QString TextHandler::editString() const { Kirigami::Platform::PlatformTheme *theme = static_cast(qmlAttachedPropertiesObject(this, true)); QString editTextColor; if (theme != nullptr) { editTextColor = theme->disabledTextColor().name(); } else { editTextColor = u"#000000"_s; } return u" (edited)"_s; } QString TextHandler::emoteString(const NeoChatRoom *room, const Quotient::RoomEvent *event) const { if (room == nullptr || event == nullptr) { return {}; } auto e = eventCast(event); auto author = room->member(e->senderId()); return u"* senderId() + u"\" style=\"color:"_s + author.color().name() + u"\">"_s + author.htmlSafeDisplayName() + u" "_s; } QString TextHandler::convertCodeLanguageString(const QString &languageString) { const int equalsPos = languageString.indexOf(u'-'); return languageString.right(languageString.length() - equalsPos - 1); } QString TextHandler::updateSpoilerText(QObject *object, QString string, bool spoilerRevealed) { auto it = QRegularExpression(u"]*data-mx-spoiler[^>]*style=\"color: (.*?); background: (.*?);\">"_s).globalMatch(string); Kirigami::Platform::PlatformTheme *theme = static_cast(qmlAttachedPropertiesObject(object, true)); int offset = 0; while (it.hasNext()) { const QRegularExpressionMatch match = it.next(); const auto newColor = spoilerRevealed ? theme->textColor().name() : u"transparent"_s; string.replace(match.capturedStart(2) + offset, match.capturedLength(2), theme->alternateBackgroundColor().name()); string.replace(match.capturedStart(1) + offset, match.capturedLength(1), newColor); offset = newColor.length() - match.capturedLength(1); } return string; } #include "moc_texthandler.cpp"