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; \"> </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> </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