1 /*
2    SPDX-FileCopyrightText: 2015-2021 Laurent Montel <montel@kde.org>
3 
4    SPDX-License-Identifier: GPL-2.0-or-later
5 */
6 
7 #include "richtextcomposerng.h"
8 #include "richtextcomposersignatures.h"
9 #include "settings/messagecomposersettings.h"
10 #include <KPIMTextEdit/MarkupDirector>
11 #include <KPIMTextEdit/PlainTextMarkupBuilder>
12 #include <KPIMTextEdit/RichTextComposerControler>
13 #include <KPIMTextEdit/RichTextComposerImages>
14 #include <KPIMTextEdit/TextHTMLBuilder>
15 #include <PimCommon/AutoCorrection>
16 #include <part/textpart.h>
17 
18 #include <QRegularExpression>
19 
20 #define USE_TEXTHTML_BUILDER 1
21 
22 using namespace MessageComposer;
23 
24 class MessageComposer::RichTextComposerNgPrivate
25 {
26 public:
RichTextComposerNgPrivate(RichTextComposerNg * q)27     explicit RichTextComposerNgPrivate(RichTextComposerNg *q)
28         : richtextComposer(q)
29     {
30         richTextComposerSignatures = new MessageComposer::RichTextComposerSignatures(richtextComposer, richtextComposer);
31     }
32 
33     void fixHtmlFontSize(QString &cleanHtml) const;
34     Q_REQUIRED_RESULT QString toCleanHtml() const;
35     PimCommon::AutoCorrection *autoCorrection = nullptr;
36     RichTextComposerNg *const richtextComposer;
37     MessageComposer::RichTextComposerSignatures *richTextComposerSignatures = nullptr;
38 };
39 
RichTextComposerNg(QWidget * parent)40 RichTextComposerNg::RichTextComposerNg(QWidget *parent)
41     : KPIMTextEdit::RichTextComposer(parent)
42     , d(new MessageComposer::RichTextComposerNgPrivate(this))
43 {
44 }
45 
46 RichTextComposerNg::~RichTextComposerNg() = default;
47 
composerSignature() const48 MessageComposer::RichTextComposerSignatures *RichTextComposerNg::composerSignature() const
49 {
50     return d->richTextComposerSignatures;
51 }
52 
autocorrection() const53 PimCommon::AutoCorrection *RichTextComposerNg::autocorrection() const
54 {
55     return d->autoCorrection;
56 }
57 
setAutocorrection(PimCommon::AutoCorrection * autocorrect)58 void RichTextComposerNg::setAutocorrection(PimCommon::AutoCorrection *autocorrect)
59 {
60     d->autoCorrection = autocorrect;
61 }
62 
setAutocorrectionLanguage(const QString & lang)63 void RichTextComposerNg::setAutocorrectionLanguage(const QString &lang)
64 {
65     if (d->autoCorrection) {
66         d->autoCorrection->setLanguage(lang);
67     }
68 }
69 
isSpecial(const QTextCharFormat & charFormat)70 static bool isSpecial(const QTextCharFormat &charFormat)
71 {
72     return charFormat.isFrameFormat() || charFormat.isImageFormat() || charFormat.isListFormat() || charFormat.isTableFormat()
73         || charFormat.isTableCellFormat();
74 }
75 
processModifyText(QKeyEvent * e)76 bool RichTextComposerNg::processModifyText(QKeyEvent *e)
77 {
78     if (d->autoCorrection && d->autoCorrection->isEnabledAutoCorrection()) {
79         if ((e->key() == Qt::Key_Space) || (e->key() == Qt::Key_Enter) || (e->key() == Qt::Key_Return)) {
80             if (!isLineQuoted(textCursor().block().text()) && !textCursor().hasSelection()) {
81                 const QTextCharFormat initialTextFormat = textCursor().charFormat();
82                 const bool richText = (textMode() == RichTextComposer::Rich);
83                 int position = textCursor().position();
84                 const bool addSpace = d->autoCorrection->autocorrect(richText, *document(), position);
85                 QTextCursor cur = textCursor();
86                 cur.setPosition(position);
87                 const bool spacePressed = (e->key() == Qt::Key_Space);
88                 if (overwriteMode() && spacePressed) {
89                     if (addSpace) {
90                         const QChar insertChar = QLatin1Char(' ');
91                         if (!cur.atBlockEnd()) {
92                             cur.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, 1);
93                         }
94                         if (richText && !isSpecial(initialTextFormat)) {
95                             cur.insertText(insertChar, initialTextFormat);
96                         } else {
97                             cur.insertText(insertChar);
98                         }
99                         setTextCursor(cur);
100                     }
101                 } else {
102                     const QChar insertChar = spacePressed ? QLatin1Char(' ') : QLatin1Char('\n');
103                     if (richText && !isSpecial(initialTextFormat)) {
104                         if ((spacePressed && addSpace) || !spacePressed) {
105                             cur.insertText(insertChar, initialTextFormat);
106                         }
107                     } else {
108                         if ((spacePressed && addSpace) || !spacePressed) {
109                             cur.insertText(insertChar);
110                         }
111                     }
112                     setTextCursor(cur);
113                 }
114                 return true;
115             }
116         }
117     }
118     return false;
119 }
120 
fixHtmlFontSize(QString & cleanHtml) const121 void RichTextComposerNgPrivate::fixHtmlFontSize(QString &cleanHtml) const
122 {
123     // non-greedy matching
124     static const QRegularExpression styleRegex(QStringLiteral("<span style=\".*?font-size:(.*?)pt;.*?</span>"));
125 
126     QRegularExpressionMatch rmatch;
127     int offset = 0;
128     while (cleanHtml.indexOf(styleRegex, offset, &rmatch) != -1) {
129         QString replacement;
130         bool ok = false;
131         const double ptValue = rmatch.captured(1).toDouble(&ok);
132         if (ok) {
133             const double emValue = ptValue / 12;
134             replacement = QString::number(emValue, 'g', 2);
135             const int capLen = rmatch.capturedLength(1);
136             cleanHtml.replace(rmatch.capturedStart(1), capLen + 2 /* QLatin1String("pt").size() */, replacement + QLatin1String("em"));
137             // advance the offset to just after the last replace
138             offset = rmatch.capturedEnd(0) - capLen + replacement.size();
139         } else {
140             // a match was found but the toDouble call failed, advance the offset to just after
141             // the entire match
142             offset = rmatch.capturedEnd(0);
143         }
144     }
145 }
146 
convertPlainText(MessageComposer::TextPart * textPart)147 MessageComposer::PluginEditorConvertTextInterface::ConvertTextStatus RichTextComposerNg::convertPlainText(MessageComposer::TextPart *textPart)
148 {
149     Q_UNUSED(textPart)
150     return MessageComposer::PluginEditorConvertTextInterface::ConvertTextStatus::NotConverted;
151 }
152 
fillComposerTextPart(MessageComposer::TextPart * textPart)153 void RichTextComposerNg::fillComposerTextPart(MessageComposer::TextPart *textPart)
154 {
155     const bool wasConverted = convertPlainText(textPart) == MessageComposer::PluginEditorConvertTextInterface::ConvertTextStatus::Converted;
156     if (composerControler()->isFormattingUsed()) {
157         if (!wasConverted) {
158             if (MessageComposer::MessageComposerSettings::self()->improvePlainTextOfHtmlMessage()) {
159                 auto pb = new KPIMTextEdit::PlainTextMarkupBuilder();
160 
161                 auto pmd = new KPIMTextEdit::MarkupDirector(pb);
162                 pmd->processDocument(document());
163                 const QString plainText = pb->getResult();
164                 textPart->setCleanPlainText(composerControler()->toCleanPlainText(plainText));
165                 auto doc = new QTextDocument(plainText);
166                 doc->adjustSize();
167 
168                 textPart->setWrappedPlainText(composerControler()->toWrappedPlainText(doc));
169                 delete doc;
170                 delete pmd;
171                 delete pb;
172             } else {
173                 textPart->setCleanPlainText(composerControler()->toCleanPlainText());
174                 textPart->setWrappedPlainText(composerControler()->toWrappedPlainText());
175             }
176         }
177     } else {
178         if (!wasConverted) {
179             textPart->setCleanPlainText(composerControler()->toCleanPlainText());
180             textPart->setWrappedPlainText(composerControler()->toWrappedPlainText());
181         }
182     }
183     textPart->setWordWrappingEnabled(lineWrapMode() == QTextEdit::FixedColumnWidth);
184     if (composerControler()->isFormattingUsed() && !wasConverted) {
185 #ifdef USE_TEXTHTML_BUILDER
186         auto pb = new KPIMTextEdit::TextHTMLBuilder();
187 
188         auto pmd = new KPIMTextEdit::MarkupDirector(pb);
189         pmd->processDocument(document());
190         QString cleanHtml =
191             QStringLiteral("<html>\n<head>\n<meta http-equiv=\"content-type\" content=\"text/html; charset=UTF-8\">\n</head>\n<body>%1</body>\n</html>")
192                 .arg(pb->getResult());
193         delete pmd;
194         delete pb;
195         d->fixHtmlFontSize(cleanHtml);
196         textPart->setCleanHtml(cleanHtml);
197         // qDebug() << " cleanHtml  grantlee builder" << cleanHtml;
198         // qDebug() << " d->toCleanHtml() " << d->toCleanHtml();
199 #else
200         QString cleanHtml = d->toCleanHtml();
201         d->fixHtmlFontSize(cleanHtml);
202         textPart->setCleanHtml(cleanHtml);
203         qDebug() << "cleanHtml  " << cleanHtml;
204 #endif
205         textPart->setEmbeddedImages(composerControler()->composerImages()->embeddedImages());
206     }
207 }
208 
toCleanHtml() const209 QString RichTextComposerNgPrivate::toCleanHtml() const
210 {
211     QString result = richtextComposer->toHtml();
212 
213     static const QString EMPTYLINEHTML = QStringLiteral(
214         "<p style=\"-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; "
215         "margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; \">&nbsp;</p>");
216 
217     // Qt inserts various style properties based on the current mode of the editor (underline,
218     // bold, etc), but only empty paragraphs *also* have qt-paragraph-type set to 'empty'.
219     // Minimal/non-greedy matching
220     static const QString EMPTYLINEREGEX = QStringLiteral("<p style=\"-qt-paragraph-type:empty;(?:.*?)</p>");
221 
222     static const QString OLLISTPATTERNQT = QStringLiteral("<ol style=\"margin-top: 0px; margin-bottom: 0px; margin-left: 0px;");
223 
224     static const QString ULLISTPATTERNQT = QStringLiteral("<ul style=\"margin-top: 0px; margin-bottom: 0px; margin-left: 0px;");
225 
226     static const QString ORDEREDLISTHTML = QStringLiteral("<ol style=\"margin-top: 0px; margin-bottom: 0px;");
227 
228     static const QString UNORDEREDLISTHTML = QStringLiteral("<ul style=\"margin-top: 0px; margin-bottom: 0px;");
229 
230     // fix 1 - empty lines should show as empty lines - MS Outlook treats margin-top:0px; as
231     // a non-existing line.
232     // Although we can simply remove the margin-top style property, we still get unwanted results
233     // if you have three or more empty lines. It's best to replace empty <p> elements with <p>&nbsp;</p>.
234 
235     // Replace all the matching text with the new line text
236     result.replace(QRegularExpression(EMPTYLINEREGEX), EMPTYLINEHTML);
237 
238     // fix 2a - ordered lists - MS Outlook treats margin-left:0px; as
239     // a non-existing number; e.g: "1. First item" turns into "First Item"
240     result.replace(OLLISTPATTERNQT, ORDEREDLISTHTML);
241 
242     // fix 2b - unordered lists - MS Outlook treats margin-left:0px; as
243     // a non-existing bullet; e.g: "* First bullet" turns into "First Bullet"
244     result.replace(ULLISTPATTERNQT, UNORDEREDLISTHTML);
245 
246     return result;
247 }
248 
isCursorAtEndOfLine(const QTextCursor & cursor)249 static bool isCursorAtEndOfLine(const QTextCursor &cursor)
250 {
251     QTextCursor testCursor = cursor;
252     testCursor.movePosition(QTextCursor::EndOfLine, QTextCursor::KeepAnchor);
253     return !testCursor.hasSelection();
254 }
255 
insertSignatureHelper(const QString & signature,RichTextComposerNg * textEdit,KIdentityManagement::Signature::Placement placement,bool isHtml,bool addNewlines)256 static void insertSignatureHelper(const QString &signature,
257                                   RichTextComposerNg *textEdit,
258                                   KIdentityManagement::Signature::Placement placement,
259                                   bool isHtml,
260                                   bool addNewlines)
261 {
262     if (!signature.isEmpty()) {
263         // Save the modified state of the document, as inserting a signature
264         // shouldn't change this. Restore it at the end of this function.
265         bool isModified = textEdit->document()->isModified();
266 
267         // Move to the desired position, where the signature should be inserted
268         QTextCursor cursor = textEdit->textCursor();
269         QTextCursor oldCursor = cursor;
270         cursor.beginEditBlock();
271 
272         if (placement == KIdentityManagement::Signature::End) {
273             cursor.movePosition(QTextCursor::End);
274         } else if (placement == KIdentityManagement::Signature::Start) {
275             cursor.movePosition(QTextCursor::Start);
276         } else if (placement == KIdentityManagement::Signature::AtCursor) {
277             cursor.movePosition(QTextCursor::StartOfLine);
278         }
279         textEdit->setTextCursor(cursor);
280 
281         QString lineSep;
282         if (addNewlines) {
283             if (isHtml) {
284                 lineSep = QStringLiteral("<br>");
285             } else {
286                 lineSep = QLatin1Char('\n');
287             }
288         }
289 
290         // Insert the signature and newlines depending on where it was inserted.
291         int newCursorPos = -1;
292         QString headSep;
293         QString tailSep;
294 
295         if (placement == KIdentityManagement::Signature::End) {
296             // There is one special case when re-setting the old cursor: The cursor
297             // was at the end. In this case, QTextEdit has no way to know
298             // if the signature was added before or after the cursor, and just
299             // decides that it was added before (and the cursor moves to the end,
300             // but it should not when appending a signature). See bug 167961
301             if (oldCursor.position() == textEdit->toPlainText().length()) {
302                 newCursorPos = oldCursor.position();
303             }
304             headSep = lineSep;
305         } else if (placement == KIdentityManagement::Signature::Start) {
306             // When prepending signatures, add a couple of new lines before
307             // the signature, and move the cursor to the beginning of the QTextEdit.
308             // People tends to insert new text there.
309             newCursorPos = 0;
310             headSep = lineSep + lineSep;
311             if (!isCursorAtEndOfLine(cursor)) {
312                 tailSep = lineSep;
313             }
314         } else if (placement == KIdentityManagement::Signature::AtCursor) {
315             if (!isCursorAtEndOfLine(cursor)) {
316                 tailSep = lineSep;
317             }
318         }
319 
320         const QString full_signature = headSep + signature + tailSep;
321         if (isHtml) {
322             textEdit->insertHtml(full_signature);
323         } else {
324             textEdit->insertPlainText(full_signature);
325         }
326 
327         cursor.endEditBlock();
328         if (newCursorPos != -1) {
329             oldCursor.setPosition(newCursorPos);
330         }
331 
332         textEdit->setTextCursor(oldCursor);
333         textEdit->ensureCursorVisible();
334 
335         textEdit->document()->setModified(isModified);
336 
337         if (isHtml) {
338             textEdit->activateRichText();
339         }
340     }
341 }
342 
insertSignature(const KIdentityManagement::Signature & signature,KIdentityManagement::Signature::Placement placement,KIdentityManagement::Signature::AddedText addedText)343 void RichTextComposerNg::insertSignature(const KIdentityManagement::Signature &signature,
344                                          KIdentityManagement::Signature::Placement placement,
345                                          KIdentityManagement::Signature::AddedText addedText)
346 {
347     if (signature.isEnabledSignature()) {
348         QString signatureStr;
349         if (addedText & KIdentityManagement::Signature::AddSeparator) {
350             signatureStr = signature.withSeparator();
351         } else {
352             signatureStr = signature.rawText();
353         }
354 
355         insertSignatureHelper(signatureStr,
356                               this,
357                               placement,
358                               (signature.isInlinedHtml() && signature.type() == KIdentityManagement::Signature::Inlined),
359                               (addedText & KIdentityManagement::Signature::AddNewLines));
360 
361         // We added the text of the signature above, now it is time to add the images as well.
362         if (signature.isInlinedHtml()) {
363             const QVector<KIdentityManagement::Signature::EmbeddedImagePtr> embeddedImages = signature.embeddedImages();
364             for (const KIdentityManagement::Signature::EmbeddedImagePtr &image : embeddedImages) {
365                 composerControler()->composerImages()->loadImage(image->image, image->name, image->name);
366             }
367         }
368     }
369 }
370 
toCleanHtml() const371 QString RichTextComposerNg::toCleanHtml() const
372 {
373     return d->toCleanHtml();
374 }
375 
fixHtmlFontSize(QString & cleanHtml) const376 void RichTextComposerNg::fixHtmlFontSize(QString &cleanHtml) const
377 {
378     d->fixHtmlFontSize(cleanHtml);
379 }
380 
forceAutoCorrection(bool selectedText)381 void RichTextComposerNg::forceAutoCorrection(bool selectedText)
382 {
383     if (document()->isEmpty()) {
384         return;
385     }
386     if (d->autoCorrection && d->autoCorrection->isEnabledAutoCorrection()) {
387         const bool richText = (textMode() == RichTextComposer::Rich);
388         const int initialPosition = textCursor().position();
389         QTextCursor cur = textCursor();
390         cur.beginEditBlock();
391         if (selectedText && cur.hasSelection()) {
392             const int positionStart = qMin(cur.selectionEnd(), cur.selectionStart());
393             const int positionEnd = qMax(cur.selectionEnd(), cur.selectionStart());
394             cur.setPosition(positionStart);
395             int cursorPosition = positionStart;
396             while (cursorPosition < positionEnd) {
397                 if (isLineQuoted(cur.block().text())) {
398                     cur.movePosition(QTextCursor::NextBlock);
399                 } else {
400                     cur.movePosition(QTextCursor::NextWord);
401                 }
402                 cursorPosition = cur.position();
403                 (void)d->autoCorrection->autocorrect(richText, *document(), cursorPosition);
404             }
405         } else {
406             cur.movePosition(QTextCursor::Start);
407             while (!cur.atEnd()) {
408                 if (isLineQuoted(cur.block().text())) {
409                     cur.movePosition(QTextCursor::NextBlock);
410                 } else {
411                     cur.movePosition(QTextCursor::NextWord);
412                 }
413                 int cursorPosition = cur.position();
414                 (void)d->autoCorrection->autocorrect(richText, *document(), cursorPosition);
415             }
416         }
417         cur.endEditBlock();
418         if (cur.position() != initialPosition) {
419             cur.setPosition(initialPosition);
420             setTextCursor(cur);
421         }
422     }
423 }
424