File indexing completed on 2024-06-23 05:18:25

0001 /*
0002    SPDX-FileCopyrightText: 2015-2024 Laurent Montel <montel@kde.org>
0003 
0004    SPDX-License-Identifier: GPL-2.0-or-later
0005 */
0006 
0007 #include "richtextcomposerng.h"
0008 #include "richtextcomposersignatures.h"
0009 #include "settings/messagecomposersettings.h"
0010 #include <KPIMTextEdit/MarkupDirector>
0011 #include <KPIMTextEdit/PlainTextMarkupBuilder>
0012 #include <KPIMTextEdit/RichTextComposerControler>
0013 #include <KPIMTextEdit/RichTextComposerImages>
0014 #include <KPIMTextEdit/TextHTMLBuilder>
0015 
0016 #include <TextAutoCorrectionCore/AutoCorrection>
0017 
0018 #include "part/textpart.h"
0019 
0020 #include <KMessageBox>
0021 
0022 #include <QRegularExpression>
0023 
0024 #define USE_TEXTHTML_BUILDER 1
0025 
0026 using namespace MessageComposer;
0027 
0028 class MessageComposer::RichTextComposerNgPrivate
0029 {
0030 public:
0031     explicit RichTextComposerNgPrivate(RichTextComposerNg *q)
0032         : richtextComposer(q)
0033         , richTextComposerSignatures(new MessageComposer::RichTextComposerSignatures(richtextComposer, richtextComposer))
0034     {
0035     }
0036 
0037     void fixHtmlFontSize(QString &cleanHtml) const;
0038     [[nodiscard]] QString toCleanHtml() const;
0039     TextAutoCorrectionCore::AutoCorrection *autoCorrection = nullptr;
0040     RichTextComposerNg *const richtextComposer;
0041     MessageComposer::RichTextComposerSignatures *const richTextComposerSignatures;
0042 };
0043 
0044 RichTextComposerNg::RichTextComposerNg(QWidget *parent)
0045     : KPIMTextEdit::RichTextComposer(parent)
0046     , d(new MessageComposer::RichTextComposerNgPrivate(this))
0047 {
0048 }
0049 
0050 RichTextComposerNg::~RichTextComposerNg() = default;
0051 
0052 MessageComposer::RichTextComposerSignatures *RichTextComposerNg::composerSignature() const
0053 {
0054     return d->richTextComposerSignatures;
0055 }
0056 
0057 TextAutoCorrectionCore::AutoCorrection *RichTextComposerNg::autocorrection() const
0058 {
0059     return d->autoCorrection;
0060 }
0061 
0062 void RichTextComposerNg::setAutocorrection(TextAutoCorrectionCore::AutoCorrection *autocorrect)
0063 {
0064     d->autoCorrection = autocorrect;
0065 }
0066 
0067 void RichTextComposerNg::setAutocorrectionLanguage(const QString &lang)
0068 {
0069     if (d->autoCorrection) {
0070         TextAutoCorrectionCore::AutoCorrectionSettings *settings = d->autoCorrection->autoCorrectionSettings();
0071         settings->setLanguage(lang);
0072         d->autoCorrection->setAutoCorrectionSettings(settings);
0073     }
0074 }
0075 
0076 static bool isSpecial(const QTextCharFormat &charFormat)
0077 {
0078     return charFormat.isFrameFormat() || charFormat.isImageFormat() || charFormat.isListFormat() || charFormat.isTableFormat()
0079         || charFormat.isTableCellFormat();
0080 }
0081 
0082 bool RichTextComposerNg::processModifyText(QKeyEvent *e)
0083 {
0084     if (d->autoCorrection && d->autoCorrection->autoCorrectionSettings()->isEnabledAutoCorrection()) {
0085         if ((e->key() == Qt::Key_Space) || (e->key() == Qt::Key_Enter) || (e->key() == Qt::Key_Return)) {
0086             if (!isLineQuoted(textCursor().block().text()) && !textCursor().hasSelection()) {
0087                 const QTextCharFormat initialTextFormat = textCursor().charFormat();
0088                 const bool richText = (textMode() == RichTextComposer::Rich);
0089                 int position = textCursor().position();
0090                 const bool addSpace = d->autoCorrection->autocorrect(richText, *document(), position);
0091                 QTextCursor cur = textCursor();
0092                 cur.setPosition(position);
0093                 const bool spacePressed = (e->key() == Qt::Key_Space);
0094                 if (overwriteMode() && spacePressed) {
0095                     if (addSpace) {
0096                         const QChar insertChar = QLatin1Char(' ');
0097                         if (!cur.atBlockEnd()) {
0098                             cur.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, 1);
0099                         }
0100                         if (richText && !isSpecial(initialTextFormat)) {
0101                             cur.insertText(insertChar, initialTextFormat);
0102                         } else {
0103                             cur.insertText(insertChar);
0104                         }
0105                         setTextCursor(cur);
0106                     }
0107                 } else {
0108                     const QChar insertChar = spacePressed ? QLatin1Char(' ') : QLatin1Char('\n');
0109                     if (richText && !isSpecial(initialTextFormat)) {
0110                         if ((spacePressed && addSpace) || !spacePressed) {
0111                             cur.insertText(insertChar, initialTextFormat);
0112                         }
0113                     } else {
0114                         if ((spacePressed && addSpace) || !spacePressed) {
0115                             cur.insertText(insertChar);
0116                         }
0117                     }
0118                     setTextCursor(cur);
0119                 }
0120                 return true;
0121             }
0122         }
0123     }
0124     return false;
0125 }
0126 
0127 void RichTextComposerNgPrivate::fixHtmlFontSize(QString &cleanHtml) const
0128 {
0129     // non-greedy matching
0130     static const QRegularExpression styleRegex(QStringLiteral("<span style=\".*?font-size:(.*?)pt;.*?</span>"));
0131 
0132     QRegularExpressionMatch rmatch;
0133     int offset = 0;
0134     while (cleanHtml.indexOf(styleRegex, offset, &rmatch) != -1) {
0135         QString replacement;
0136         bool ok = false;
0137         const double ptValue = rmatch.captured(1).toDouble(&ok);
0138         if (ok) {
0139             const double emValue = ptValue / 12;
0140             replacement = QString::number(emValue, 'g', 2);
0141             const int capLen = rmatch.capturedLength(1);
0142             cleanHtml.replace(rmatch.capturedStart(1), capLen + 2 /* QLatin1StringView("pt").size() */, replacement + QLatin1StringView("em"));
0143             // advance the offset to just after the last replace
0144             offset = rmatch.capturedEnd(0) - capLen + replacement.size();
0145         } else {
0146             // a match was found but the toDouble call failed, advance the offset to just after
0147             // the entire match
0148             offset = rmatch.capturedEnd(0);
0149         }
0150     }
0151 }
0152 
0153 MessageComposer::PluginEditorConvertTextInterface::ConvertTextStatus RichTextComposerNg::convertPlainText(MessageComposer::TextPart *textPart)
0154 {
0155     Q_UNUSED(textPart)
0156     return MessageComposer::PluginEditorConvertTextInterface::ConvertTextStatus::NotConverted;
0157 }
0158 
0159 void RichTextComposerNg::fillComposerTextPart(MessageComposer::TextPart *textPart)
0160 {
0161     const bool wasConverted = convertPlainText(textPart) == MessageComposer::PluginEditorConvertTextInterface::ConvertTextStatus::Converted;
0162     if (composerControler()->isFormattingUsed()) {
0163         if (!wasConverted) {
0164             if (MessageComposer::MessageComposerSettings::self()->improvePlainTextOfHtmlMessage()) {
0165                 auto pb = new KPIMTextEdit::PlainTextMarkupBuilder();
0166 
0167                 auto pmd = new KPIMTextEdit::MarkupDirector(pb);
0168                 pmd->processDocument(document());
0169                 const QString plainText = pb->getResult();
0170                 textPart->setCleanPlainText(composerControler()->toCleanPlainText(plainText));
0171                 auto doc = new QTextDocument(plainText);
0172                 doc->adjustSize();
0173 
0174                 textPart->setWrappedPlainText(composerControler()->toWrappedPlainText(doc));
0175                 delete doc;
0176                 delete pmd;
0177                 delete pb;
0178             } else {
0179                 textPart->setCleanPlainText(composerControler()->toCleanPlainText());
0180                 textPart->setWrappedPlainText(composerControler()->toWrappedPlainText());
0181             }
0182         }
0183     } else {
0184         if (!wasConverted) {
0185             textPart->setCleanPlainText(composerControler()->toCleanPlainText());
0186             textPart->setWrappedPlainText(composerControler()->toWrappedPlainText());
0187         }
0188     }
0189     textPart->setWordWrappingEnabled(lineWrapMode() == QTextEdit::FixedColumnWidth);
0190     if (composerControler()->isFormattingUsed() && !wasConverted) {
0191 #ifdef USE_TEXTHTML_BUILDER
0192         auto pb = new KPIMTextEdit::TextHTMLBuilder();
0193 
0194         auto pmd = new KPIMTextEdit::MarkupDirector(pb);
0195         pmd->processDocument(document());
0196         QString cleanHtml =
0197             QStringLiteral("<html>\n<head>\n<meta http-equiv=\"content-type\" content=\"text/html; charset=UTF-8\">\n</head>\n<body>%1</body>\n</html>")
0198                 .arg(pb->getResult());
0199         delete pmd;
0200         delete pb;
0201         d->fixHtmlFontSize(cleanHtml);
0202         textPart->setCleanHtml(cleanHtml);
0203         // qDebug() << " cleanHtml  grantlee builder" << cleanHtml;
0204         // qDebug() << " d->toCleanHtml() " << d->toCleanHtml();
0205 #else
0206         QString cleanHtml = d->toCleanHtml();
0207         d->fixHtmlFontSize(cleanHtml);
0208         textPart->setCleanHtml(cleanHtml);
0209         qDebug() << "cleanHtml  " << cleanHtml;
0210 #endif
0211         textPart->setEmbeddedImages(composerControler()->composerImages()->embeddedImages());
0212     }
0213 }
0214 
0215 QString RichTextComposerNgPrivate::toCleanHtml() const
0216 {
0217     QString result = richtextComposer->toHtml();
0218 
0219     static const QString EMPTYLINEHTML = QStringLiteral(
0220         "<p style=\"-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; "
0221         "margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; \">&nbsp;</p>");
0222 
0223     // Qt inserts various style properties based on the current mode of the editor (underline,
0224     // bold, etc), but only empty paragraphs *also* have qt-paragraph-type set to 'empty'.
0225     // Minimal/non-greedy matching
0226     static const QString EMPTYLINEREGEX = QStringLiteral("<p style=\"-qt-paragraph-type:empty;(?:.*?)</p>");
0227 
0228     static const QString OLLISTPATTERNQT = QStringLiteral("<ol style=\"margin-top: 0px; margin-bottom: 0px; margin-left: 0px;");
0229 
0230     static const QString ULLISTPATTERNQT = QStringLiteral("<ul style=\"margin-top: 0px; margin-bottom: 0px; margin-left: 0px;");
0231 
0232     static const QString ORDEREDLISTHTML = QStringLiteral("<ol style=\"margin-top: 0px; margin-bottom: 0px;");
0233 
0234     static const QString UNORDEREDLISTHTML = QStringLiteral("<ul style=\"margin-top: 0px; margin-bottom: 0px;");
0235 
0236     // fix 1 - empty lines should show as empty lines - MS Outlook treats margin-top:0px; as
0237     // a non-existing line.
0238     // Although we can simply remove the margin-top style property, we still get unwanted results
0239     // if you have three or more empty lines. It's best to replace empty <p> elements with <p>&nbsp;</p>.
0240 
0241     // Replace all the matching text with the new line text
0242     result.replace(QRegularExpression(EMPTYLINEREGEX), EMPTYLINEHTML);
0243 
0244     // fix 2a - ordered lists - MS Outlook treats margin-left:0px; as
0245     // a non-existing number; e.g: "1. First item" turns into "First Item"
0246     result.replace(OLLISTPATTERNQT, ORDEREDLISTHTML);
0247 
0248     // fix 2b - unordered lists - MS Outlook treats margin-left:0px; as
0249     // a non-existing bullet; e.g: "* First bullet" turns into "First Bullet"
0250     result.replace(ULLISTPATTERNQT, UNORDEREDLISTHTML);
0251 
0252     return result;
0253 }
0254 
0255 static bool isCursorAtEndOfLine(const QTextCursor &cursor)
0256 {
0257     QTextCursor testCursor = cursor;
0258     testCursor.movePosition(QTextCursor::EndOfLine, QTextCursor::KeepAnchor);
0259     return !testCursor.hasSelection();
0260 }
0261 
0262 static void insertSignatureHelper(const QString &signature,
0263                                   RichTextComposerNg *textEdit,
0264                                   KIdentityManagementCore::Signature::Placement placement,
0265                                   bool isHtml,
0266                                   bool addNewlines)
0267 {
0268     if (!signature.isEmpty()) {
0269         // Save the modified state of the document, as inserting a signature
0270         // shouldn't change this. Restore it at the end of this function.
0271         bool isModified = textEdit->document()->isModified();
0272 
0273         // Move to the desired position, where the signature should be inserted
0274         QTextCursor cursor = textEdit->textCursor();
0275         QTextCursor oldCursor = cursor;
0276         cursor.beginEditBlock();
0277 
0278         if (placement == KIdentityManagementCore::Signature::End) {
0279             cursor.movePosition(QTextCursor::End);
0280         } else if (placement == KIdentityManagementCore::Signature::Start) {
0281             cursor.movePosition(QTextCursor::Start);
0282         } else if (placement == KIdentityManagementCore::Signature::AtCursor) {
0283             cursor.movePosition(QTextCursor::StartOfLine);
0284         }
0285         textEdit->setTextCursor(cursor);
0286 
0287         QString lineSep;
0288         if (addNewlines) {
0289             if (isHtml) {
0290                 lineSep = QStringLiteral("<br>");
0291             } else {
0292                 lineSep = QLatin1Char('\n');
0293             }
0294         }
0295 
0296         // Insert the signature and newlines depending on where it was inserted.
0297         int newCursorPos = -1;
0298         QString headSep;
0299         QString tailSep;
0300 
0301         if (placement == KIdentityManagementCore::Signature::End) {
0302             // There is one special case when re-setting the old cursor: The cursor
0303             // was at the end. In this case, QTextEdit has no way to know
0304             // if the signature was added before or after the cursor, and just
0305             // decides that it was added before (and the cursor moves to the end,
0306             // but it should not when appending a signature). See bug 167961
0307             if (oldCursor.position() == textEdit->toPlainText().length()) {
0308                 newCursorPos = oldCursor.position();
0309             }
0310             headSep = lineSep;
0311         } else if (placement == KIdentityManagementCore::Signature::Start) {
0312             // When prepending signatures, add a couple of new lines before
0313             // the signature, and move the cursor to the beginning of the QTextEdit.
0314             // People tends to insert new text there.
0315             newCursorPos = 0;
0316             headSep = lineSep + lineSep;
0317             if (!isCursorAtEndOfLine(cursor)) {
0318                 tailSep = lineSep;
0319             }
0320         } else if (placement == KIdentityManagementCore::Signature::AtCursor) {
0321             if (!isCursorAtEndOfLine(cursor)) {
0322                 tailSep = lineSep;
0323             }
0324         }
0325 
0326         const QString full_signature = headSep + signature + tailSep;
0327         if (isHtml) {
0328             textEdit->insertHtml(full_signature);
0329         } else {
0330             textEdit->insertPlainText(full_signature);
0331         }
0332 
0333         cursor.endEditBlock();
0334         if (newCursorPos != -1) {
0335             oldCursor.setPosition(newCursorPos);
0336         }
0337 
0338         textEdit->setTextCursor(oldCursor);
0339         textEdit->ensureCursorVisible();
0340 
0341         textEdit->document()->setModified(isModified);
0342 
0343         if (isHtml) {
0344             textEdit->activateRichText();
0345         }
0346     }
0347 }
0348 
0349 void RichTextComposerNg::insertSignature(const KIdentityManagementCore::Signature &signature,
0350                                          KIdentityManagementCore::Signature::Placement placement,
0351                                          KIdentityManagementCore::Signature::AddedText addedText)
0352 {
0353     if (signature.isEnabledSignature()) {
0354         QString signatureStr;
0355         bool ok = false;
0356         QString errorMessage;
0357         if (addedText & KIdentityManagementCore::Signature::AddSeparator) {
0358             signatureStr = signature.withSeparator(&ok, &errorMessage);
0359         } else {
0360             signatureStr = signature.rawText(&ok, &errorMessage);
0361         }
0362 
0363         if (!ok && !errorMessage.isEmpty()) {
0364             KMessageBox::error(nullptr, errorMessage);
0365         }
0366 
0367         insertSignatureHelper(signatureStr,
0368                               this,
0369                               placement,
0370                               (signature.isInlinedHtml() && signature.type() == KIdentityManagementCore::Signature::Inlined),
0371                               (addedText & KIdentityManagementCore::Signature::AddNewLines));
0372 
0373         // We added the text of the signature above, now it is time to add the images as well.
0374         if (signature.isInlinedHtml()) {
0375             const QList<KIdentityManagementCore::Signature::EmbeddedImagePtr> embeddedImages = signature.embeddedImages();
0376             for (const KIdentityManagementCore::Signature::EmbeddedImagePtr &image : embeddedImages) {
0377                 composerControler()->composerImages()->loadImage(image->image, image->name, image->name);
0378             }
0379         }
0380     }
0381 }
0382 
0383 QString RichTextComposerNg::toCleanHtml() const
0384 {
0385     return d->toCleanHtml();
0386 }
0387 
0388 void RichTextComposerNg::fixHtmlFontSize(QString &cleanHtml) const
0389 {
0390     d->fixHtmlFontSize(cleanHtml);
0391 }
0392 
0393 void RichTextComposerNg::forceAutoCorrection(bool selectedText)
0394 {
0395     if (document()->isEmpty()) {
0396         return;
0397     }
0398     if (d->autoCorrection && d->autoCorrection->autoCorrectionSettings()->isEnabledAutoCorrection()) {
0399         const bool richText = (textMode() == RichTextComposer::Rich);
0400         const int initialPosition = textCursor().position();
0401         QTextCursor cur = textCursor();
0402         cur.beginEditBlock();
0403         if (selectedText && cur.hasSelection()) {
0404             const int positionStart = qMin(cur.selectionEnd(), cur.selectionStart());
0405             const int positionEnd = qMax(cur.selectionEnd(), cur.selectionStart());
0406             cur.setPosition(positionStart);
0407             int cursorPosition = positionStart;
0408             while (cursorPosition < positionEnd) {
0409                 if (isLineQuoted(cur.block().text())) {
0410                     cur.movePosition(QTextCursor::NextBlock);
0411                 } else {
0412                     cur.movePosition(QTextCursor::NextWord);
0413                 }
0414                 cursorPosition = cur.position();
0415                 (void)d->autoCorrection->autocorrect(richText, *document(), cursorPosition);
0416             }
0417         } else {
0418             cur.movePosition(QTextCursor::Start);
0419             while (!cur.atEnd()) {
0420                 if (isLineQuoted(cur.block().text())) {
0421                     cur.movePosition(QTextCursor::NextBlock);
0422                 } else {
0423                     cur.movePosition(QTextCursor::NextWord);
0424                 }
0425                 int cursorPosition = cur.position();
0426                 (void)d->autoCorrection->autocorrect(richText, *document(), cursorPosition);
0427             }
0428         }
0429         cur.endEditBlock();
0430         if (cur.position() != initialPosition) {
0431             cur.setPosition(initialPosition);
0432             setTextCursor(cur);
0433         }
0434     }
0435 }
0436 
0437 #include "moc_richtextcomposerng.cpp"