File indexing completed on 2025-03-09 04:54:35
0001 /* 0002 SPDX-FileCopyrightText: 2016 Sandro Knauß <sknauss@kde.org> 0003 0004 SPDX-License-Identifier: LGPL-2.0-or-later 0005 */ 0006 0007 #include "quotehtml.h" 0008 0009 #include "utils/iconnamecache.h" 0010 #include "viewer/csshelperbase.h" 0011 0012 #include <MessageViewer/HtmlWriter> 0013 #include <MessageViewer/MessagePartRendererBase> 0014 0015 #include <KTextToHTML> 0016 0017 #include <QSharedPointer> 0018 0019 /** Check if the newline at position @p newLinePos in string @p s 0020 seems to separate two paragraphs (important for correct BiDi 0021 behavior, but is heuristic because paragraphs are not 0022 well-defined) */ 0023 // Guesstimate if the newline at newLinePos actually separates paragraphs in the text s 0024 // We use several heuristics: 0025 // 1. If newLinePos points after or before (=at the very beginning of) text, it is not between paragraphs 0026 // 2. If the previous line was longer than the wrap size, we want to consider it a paragraph on its own 0027 // (some clients, notably Outlook, send each para as a line in the plain-text version). 0028 // 3. Otherwise, we check if the newline could have been inserted for wrapping around; if this 0029 // was the case, then the previous line will be shorter than the wrap size (which we already 0030 // know because of item 2 above), but adding the first word from the next line will make it 0031 // longer than the wrap size. 0032 bool looksLikeParaBreak(const QString &s, int newLinePos) 0033 { 0034 const int WRAP_COL = 78; 0035 0036 int length = s.length(); 0037 // 1. Is newLinePos at an end of the text? 0038 if (newLinePos >= length - 1 || newLinePos == 0) { 0039 return false; 0040 } 0041 0042 // 2. Is the previous line really a paragraph -- longer than the wrap size? 0043 0044 // First char of prev line -- works also for first line 0045 int prevStart = s.lastIndexOf(QLatin1Char('\n'), newLinePos - 1) + 1; 0046 int prevLineLength = newLinePos - prevStart; 0047 if (prevLineLength > WRAP_COL) { 0048 return true; 0049 } 0050 0051 // find next line to delimit search for first word 0052 int nextStart = newLinePos + 1; 0053 int nextEnd = s.indexOf(QLatin1Char('\n'), nextStart); 0054 if (nextEnd == -1) { 0055 nextEnd = length; 0056 } 0057 QString nextLine = s.mid(nextStart, nextEnd - nextStart); 0058 length = nextLine.length(); 0059 // search for first word in next line 0060 int wordStart; 0061 bool found = false; 0062 for (wordStart = 0; !found && wordStart < length; wordStart++) { 0063 switch (nextLine[wordStart].toLatin1()) { 0064 case '>': 0065 case '|': 0066 case ' ': // spaces, tabs and quote markers don't count 0067 case '\t': 0068 case '\r': 0069 break; 0070 default: 0071 found = true; 0072 break; 0073 } 0074 } /* for() */ 0075 0076 if (!found) { 0077 // next line is essentially empty, it seems -- empty lines are 0078 // para separators 0079 return true; 0080 } 0081 // Find end of first word. 0082 // Note: flowText (in kmmessage.cpp) separates words for wrap by 0083 // spaces only. This should be consistent, which calls for some 0084 // refactoring. 0085 int wordEnd = nextLine.indexOf(QLatin1Char(' '), wordStart); 0086 if (wordEnd == (-1)) { 0087 wordEnd = length; 0088 } 0089 int wordLength = wordEnd - wordStart; 0090 0091 // 3. If adding a space and the first word to the prev line don't 0092 // make it reach the wrap column, then the break was probably 0093 // meaningful 0094 return prevLineLength + wordLength + 1 < WRAP_COL; 0095 } 0096 0097 void quotedHTML(const QString &s, MessageViewer::RenderContext *context, MessageViewer::HtmlWriter *htmlWriter) 0098 { 0099 const auto cssHelper = context->cssHelper(); 0100 Q_ASSERT(cssHelper); 0101 0102 KTextToHTML::Options convertFlags = KTextToHTML::PreserveSpaces | KTextToHTML::HighlightText | KTextToHTML::ConvertPhoneNumbers; 0103 if (context->showEmoticons()) { 0104 convertFlags |= KTextToHTML::ReplaceSmileys; 0105 } 0106 0107 const QString normalStartTag = cssHelper->nonQuotedFontTag(); 0108 QString quoteFontTag[3]; 0109 QString deepQuoteFontTag[3]; 0110 for (int i = 0; i < 3; ++i) { 0111 quoteFontTag[i] = cssHelper->quoteFontTag(i); 0112 deepQuoteFontTag[i] = cssHelper->quoteFontTag(i + 3); 0113 } 0114 const QString normalEndTag = QStringLiteral("</div>"); 0115 const QString quoteEnd = QStringLiteral("</div>"); 0116 0117 const int length = s.length(); 0118 bool paraIsRTL = false; 0119 bool startNewPara = true; 0120 int pos; 0121 int beg; 0122 0123 // skip leading empty lines 0124 for (pos = 0; pos < length && s[pos] <= QLatin1Char(' '); ++pos) { } 0125 while (pos > 0 && (s[pos - 1] == QLatin1Char(' ') || s[pos - 1] == QLatin1Char('\t'))) { 0126 pos--; 0127 } 0128 beg = pos; 0129 0130 int currQuoteLevel = -2; // -2 == no previous lines 0131 bool curHidden = false; // no hide any block 0132 0133 QString collapseIconPath; 0134 QString expandIconPath; 0135 if (context->showExpandQuotesMark()) { 0136 collapseIconPath = MessageViewer::IconNameCache::instance()->iconPathFromLocal(QStringLiteral("quotecollapse.png")); 0137 expandIconPath = MessageViewer::IconNameCache::instance()->iconPathFromLocal(QStringLiteral("quoteexpand.png")); 0138 } 0139 0140 int previousQuoteDepth = -1; 0141 while (beg < length) { 0142 /* search next occurrence of '\n' */ 0143 pos = s.indexOf(QLatin1Char('\n'), beg, Qt::CaseInsensitive); 0144 if (pos == -1) { 0145 pos = length; 0146 } 0147 0148 QString line(s.mid(beg, pos - beg)); 0149 beg = pos + 1; 0150 0151 bool foundQuote = false; 0152 /* calculate line's current quoting depth */ 0153 int actQuoteLevel = -1; 0154 const int numberOfCaracters(line.length()); 0155 int quoteLength = 0; 0156 for (int p = 0; p < numberOfCaracters; ++p) { 0157 switch (line[p].toLatin1()) { 0158 case '>': 0159 case '|': 0160 if (p == 0 || foundQuote) { 0161 actQuoteLevel++; 0162 quoteLength = p; 0163 foundQuote = true; 0164 } 0165 break; 0166 case ' ': // spaces and tabs are allowed between the quote markers 0167 case '\t': 0168 case '\r': 0169 break; 0170 default: // stop quoting depth calculation 0171 p = numberOfCaracters; 0172 break; 0173 } 0174 } /* for() */ 0175 if (!foundQuote) { 0176 quoteLength = 0; 0177 } 0178 bool actHidden = false; 0179 0180 // This quoted line needs be hidden 0181 if (context->showExpandQuotesMark() && context->levelQuote() >= 0 && context->levelQuote() <= actQuoteLevel) { 0182 actHidden = true; 0183 } 0184 0185 if (actQuoteLevel != currQuoteLevel) { 0186 /* finish last quotelevel */ 0187 if (currQuoteLevel == -1) { 0188 htmlWriter->write(normalEndTag); 0189 } else if (currQuoteLevel >= 0 && !curHidden) { 0190 htmlWriter->write(quoteEnd); 0191 } 0192 // Close blockquote 0193 if (previousQuoteDepth > actQuoteLevel) { 0194 htmlWriter->write(cssHelper->addEndBlockQuote(previousQuoteDepth - actQuoteLevel)); 0195 } 0196 0197 /* start new quotelevel */ 0198 if (actQuoteLevel == -1) { 0199 htmlWriter->write(normalStartTag); 0200 } else { 0201 if (context->showExpandQuotesMark()) { 0202 // Add blockquote 0203 if (previousQuoteDepth < actQuoteLevel) { 0204 htmlWriter->write(cssHelper->addStartBlockQuote(actQuoteLevel - previousQuoteDepth)); 0205 } 0206 if (actHidden) { 0207 // only show the QuoteMark when is the first line of the level hidden 0208 if (!curHidden) { 0209 // Expand all quotes 0210 htmlWriter->write(QStringLiteral("<div class=\"quotelevelmark\" >")); 0211 htmlWriter->write(QStringLiteral("<a href=\"kmail:levelquote?%1 \">" 0212 "<img src=\"%2\"/></a>") 0213 .arg(-1) 0214 .arg(expandIconPath)); 0215 htmlWriter->write(QStringLiteral("</div><br/>")); 0216 } 0217 } else { 0218 htmlWriter->write(QStringLiteral("<div class=\"quotelevelmark\" >")); 0219 htmlWriter->write(QStringLiteral("<a href=\"kmail:levelquote?%1 \">" 0220 "<img src=\"%2\"/></a>") 0221 .arg(actQuoteLevel) 0222 .arg(collapseIconPath)); 0223 htmlWriter->write(QStringLiteral("</div>")); 0224 if (actQuoteLevel < 3) { 0225 htmlWriter->write(quoteFontTag[actQuoteLevel]); 0226 } else { 0227 htmlWriter->write(deepQuoteFontTag[actQuoteLevel % 3]); 0228 } 0229 } 0230 } else { 0231 // Add blockquote 0232 if (previousQuoteDepth < actQuoteLevel) { 0233 htmlWriter->write(cssHelper->addStartBlockQuote(actQuoteLevel - previousQuoteDepth)); 0234 } 0235 0236 if (actQuoteLevel < 3) { 0237 htmlWriter->write(quoteFontTag[actQuoteLevel]); 0238 } else { 0239 htmlWriter->write(deepQuoteFontTag[actQuoteLevel % 3]); 0240 } 0241 } 0242 } 0243 currQuoteLevel = actQuoteLevel; 0244 } 0245 curHidden = actHidden; 0246 0247 if (!actHidden) { 0248 // don't write empty <div ...></div> blocks (they have zero height) 0249 // ignore ^M DOS linebreaks 0250 if (!line.remove(QLatin1Char('\015')).isEmpty()) { 0251 if (startNewPara) { 0252 paraIsRTL = line.isRightToLeft(); 0253 } 0254 htmlWriter->write(QStringLiteral("<div dir=\"%1\">").arg(paraIsRTL ? QStringLiteral("rtl") : QStringLiteral("ltr"))); 0255 // if quoteLengh == 0 && foundQuote => a simple quote 0256 if (foundQuote) { 0257 quoteLength++; 0258 const int rightString = (line.length()) - quoteLength; 0259 if (rightString > 0) { 0260 htmlWriter->write(QStringLiteral("<span class=\"quotemarks\">%1</span>").arg(line.left(quoteLength))); 0261 htmlWriter->write(QStringLiteral("<font color=\"%1\">").arg(cssHelper->quoteColorName(actQuoteLevel))); 0262 const QString str = KTextToHTML::convertToHtml(line.right(rightString), convertFlags, 4096, 512); 0263 htmlWriter->write(str); 0264 htmlWriter->write(QStringLiteral("</font>")); 0265 } else { 0266 htmlWriter->write(QStringLiteral("<span class=\"quotemarksemptyline\">%1</span>").arg(line.left(quoteLength))); 0267 } 0268 } else { 0269 htmlWriter->write(KTextToHTML::convertToHtml(line, convertFlags, 4096, 512)); 0270 } 0271 0272 htmlWriter->write(QStringLiteral("</div>")); 0273 startNewPara = looksLikeParaBreak(s, pos); 0274 } else { 0275 htmlWriter->write(QStringLiteral("<br/>")); 0276 // after an empty line, always start a new paragraph 0277 startNewPara = true; 0278 } 0279 } 0280 previousQuoteDepth = actQuoteLevel; 0281 } /* while() */ 0282 0283 /* really finish the last quotelevel */ 0284 if (currQuoteLevel == -1) { 0285 htmlWriter->write(normalEndTag); 0286 } else { 0287 htmlWriter->write(quoteEnd + cssHelper->addEndBlockQuote(currQuoteLevel + 1)); 0288 } 0289 }