MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - ui - MarkdownRenderer.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 97.7 % 220 215
Test Date: 2026-07-27 17:53:44 Functions: 100.0 % 9 9
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 58.1 % 492 286

             Branch data     Line data    Source code
       1                 :             : #include "MarkdownRenderer.h"
       2                 :             : 
       3                 :             : #include "ui/ThemeManager.h"
       4                 :             : 
       5                 :             : #include <QRegularExpression>
       6                 :             : #include <QStringList>
       7                 :             : #include <QUrl>
       8                 :             : 
       9                 :             : // 67.B3: inline-HTML colors come from the ThemeManager @md_* tokens
      10                 :             : // (docs/DESIGN.md §2); toHtml() is re-invoked on redisplay, so theme
      11                 :             : // switches take effect on the next render.
      12                 :         201 : static QString mdTok(const char *token) {
      13   [ +  -  +  - ]:         201 :   return ThemeManager::instance().color(QLatin1String(token));
      14                 :             : }
      15                 :             : 
      16                 :             : // ═══════════════════════════════════════════════════════
      17                 :             : // MarkdownRenderer – Markdown → HTML  (Sprint 37 – T-457)
      18                 :             : // ═══════════════════════════════════════════════════════
      19                 :             : 
      20                 :         169 : QString MarkdownRenderer::escapeHtml(const QString &text) {
      21                 :         169 :   QString out = text;
      22         [ +  - ]:         169 :   out.replace(QLatin1Char('&'), QStringLiteral("&amp;"));
      23         [ +  - ]:         169 :   out.replace(QLatin1Char('<'), QStringLiteral("&lt;"));
      24         [ +  - ]:         169 :   out.replace(QLatin1Char('>'), QStringLiteral("&gt;"));
      25         [ +  - ]:         169 :   out.replace(QLatin1Char('"'), QStringLiteral("&quot;"));
      26                 :         169 :   return out;
      27                 :           0 : }
      28                 :             : 
      29                 :         161 : QString MarkdownRenderer::processInline(const QString &line) {
      30                 :         161 :   QString out = line;
      31                 :             : 
      32                 :             :   // Bold: **text**
      33                 :             :   static QRegularExpression boldRe(
      34   [ +  +  +  -  :         167 :       QStringLiteral("\\*\\*(.+?)\\*\\*"));
             +  -  -  - ]
      35         [ +  - ]:         161 :   out.replace(boldRe, QStringLiteral("<b>\\1</b>"));
      36                 :             : 
      37                 :             :   // Italic: *text* (but not inside **)
      38                 :             :   static QRegularExpression italicRe(
      39   [ +  +  +  -  :         167 :       QStringLiteral("(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)"));
             +  -  -  - ]
      40         [ +  - ]:         161 :   out.replace(italicRe, QStringLiteral("<i>\\1</i>"));
      41                 :             : 
      42                 :             :   // Inline code: `code`
      43   [ +  +  +  -  :         167 :   static QRegularExpression codeRe(QStringLiteral("`([^`]+)`"));
             +  -  -  - ]
      44         [ +  - ]:         483 :   out.replace(codeRe, QStringLiteral(
      45                 :             :       "<code style=\"background:%1;padding:2px 6px;"
      46                 :             :       "border-radius:3px;"
      47                 :             :       "font-family:'JetBrains Mono','Fira Code',monospace;"
      48   [ +  -  +  - ]:         322 :       "font-size:0.9em;\">\\1</code>").arg(mdTok("@md_inline_code_bg")));
      49                 :             : 
      50                 :             :   // Links: [text](url)
      51                 :             :   static QRegularExpression linkRe(
      52   [ +  +  +  -  :         167 :       QStringLiteral("\\[([^\\]]+)\\]\\(([^)]+)\\)"));
             +  -  -  - ]
      53         [ +  - ]:         161 :   out.replace(linkRe, QStringLiteral("<a href=\"\\2\">\\1</a>"));
      54                 :             : 
      55                 :         161 :   return out;
      56                 :           0 : }
      57                 :             : 
      58                 :          26 : QString MarkdownRenderer::toHtml(const QString &markdown) {
      59         [ +  - ]:          26 :   return render(markdown, QString()).html;
      60                 :             : }
      61                 :             : 
      62                 :         103 : MarkdownRenderer::InteractiveRender MarkdownRenderer::toInteractiveHtml(
      63                 :             :     const QString &markdown, const QString &checkboxActionToken) {
      64                 :         103 :   return render(markdown, checkboxActionToken);
      65                 :             : }
      66                 :             : 
      67                 :         129 : MarkdownRenderer::InteractiveRender MarkdownRenderer::render(
      68                 :             :     const QString &markdown, const QString &checkboxActionToken) {
      69         [ -  + ]:         129 :   if (markdown.isEmpty())
      70                 :           0 :     return {};
      71                 :             : 
      72                 :         129 :   InteractiveRender result;
      73                 :             : 
      74                 :             :   // Normalize line endings
      75                 :         129 :   QString input = markdown;
      76         [ +  - ]:         258 :   input.replace(QStringLiteral("\r\n"), QStringLiteral("\n"));
      77         [ +  - ]:         129 :   input.replace(QLatin1Char('\r'), QLatin1Char('\n'));
      78                 :             : 
      79                 :             :   // T-79.C1/H5+L28: iCal TEXT values are unescaped once, centrally, in
      80                 :             :   // CalDavClient::parseICalBlock — no display-site unescaping here (the
      81                 :             :   // old partial replaces mangled literal "\\n" into a newline).
      82                 :             : 
      83         [ +  - ]:         129 :   const QStringList rawLines = input.split(QLatin1Char('\n'));
      84                 :         129 :   QString html;
      85         [ +  - ]:         129 :   html.reserve(input.size() * 2);
      86                 :             : 
      87                 :             :   // State tracking
      88                 :         129 :   bool inCodeBlock = false;
      89                 :         129 :   bool inUl = false;
      90                 :         129 :   bool inOl = false;
      91                 :         129 :   bool inBlockquote = false;
      92                 :         129 :   bool inTable = false;
      93                 :         129 :   int tableRowIndex = 0;
      94                 :         129 :   int checkboxIndex = 0; // Sprint 56: clickable checkbox counter
      95                 :             : 
      96                 :         160 :   auto closeList = [&]() {
      97         [ +  + ]:         160 :     if (inUl) {
      98         [ +  - ]:           7 :       html += QStringLiteral("</ul>\n");
      99                 :           7 :       inUl = false;
     100                 :             :     }
     101         [ +  + ]:         160 :     if (inOl) {
     102         [ +  - ]:           1 :       html += QStringLiteral("</ol>\n");
     103                 :           1 :       inOl = false;
     104                 :             :     }
     105                 :         160 :   };
     106                 :             : 
     107                 :         171 :   auto closeBlockquote = [&]() {
     108         [ +  + ]:         171 :     if (inBlockquote) {
     109         [ +  - ]:           4 :       html += QStringLiteral("</blockquote>\n");
     110                 :           4 :       inBlockquote = false;
     111                 :             :     }
     112                 :         171 :   };
     113                 :             : 
     114                 :         164 :   auto closeTable = [&]() {
     115         [ +  + ]:         164 :     if (inTable) {
     116         [ +  - ]:           3 :       html += QStringLiteral("</table>\n");
     117                 :           3 :       inTable = false;
     118                 :           3 :       tableRowIndex = 0;
     119                 :             :     }
     120                 :         164 :   };
     121                 :             : 
     122         [ +  + ]:         304 :   for (int i = 0; i < rawLines.size(); ++i) {
     123                 :         175 :     const QString &rawLine = rawLines.at(i);
     124                 :             : 
     125                 :             :     // ── Fenced code blocks ──
     126   [ +  -  +  -  :         350 :     if (rawLine.trimmed().startsWith(QStringLiteral("```"))) {
                   +  + ]
     127         [ +  + ]:           6 :       if (!inCodeBlock) {
     128         [ +  - ]:           3 :         closeList();
     129         [ +  - ]:           3 :         closeBlockquote();
     130         [ +  - ]:           3 :         closeTable();
     131                 :           3 :         inCodeBlock = true;
     132                 :           6 :         html += QStringLiteral(
     133                 :             :             "<pre style=\"background:%1;padding:12px;"
     134                 :             :             "border-radius:6px;overflow-x:auto;"
     135                 :             :             "border:1px solid %2;"
     136                 :             :             "font-family:'JetBrains Mono','Fira Code',monospace;"
     137                 :             :             "font-size:0.9em;\"><code>")
     138   [ +  -  +  -  :           6 :                     .arg(mdTok("@md_code_bg"), mdTok("@md_table_border"));
             +  -  +  - ]
     139                 :             :       } else {
     140         [ +  - ]:           3 :         html += QStringLiteral("</code></pre>\n");
     141                 :           3 :         inCodeBlock = false;
     142                 :             :       }
     143                 :          58 :       continue;
     144                 :             :     }
     145                 :             : 
     146         [ +  + ]:         169 :     if (inCodeBlock) {
     147   [ +  -  +  - ]:           5 :       html += escapeHtml(rawLine);
     148         [ +  - ]:           5 :       html += QLatin1Char('\n');
     149                 :           5 :       continue;
     150                 :             :     }
     151                 :             : 
     152                 :             :     // Escape HTML in content lines (before inline processing)
     153         [ +  - ]:         164 :     QString line = escapeHtml(rawLine);
     154                 :             : 
     155                 :             :     // ── Horizontal rule ──
     156                 :             :     static QRegularExpression hrRe(
     157   [ +  +  +  -  :         170 :         QStringLiteral("^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$"));
             +  -  -  - ]
     158   [ +  -  +  -  :         164 :     if (hrRe.match(line).hasMatch()) {
                   +  + ]
     159         [ +  - ]:           2 :       closeList();
     160         [ +  - ]:           2 :       closeBlockquote();
     161         [ +  - ]:           2 :       closeTable();
     162         [ +  - ]:           2 :       html += QStringLiteral("<hr>\n");
     163                 :           2 :       continue;
     164                 :             :     }
     165                 :             : 
     166                 :             :     // ── Headers ──
     167                 :             :     static QRegularExpression headerRe(
     168   [ +  +  +  -  :         168 :         QStringLiteral("^(#{1,6})\\s+(.+)$"));
             +  -  -  - ]
     169         [ +  - ]:         162 :     auto headerMatch = headerRe.match(line);
     170   [ +  -  +  + ]:         162 :     if (headerMatch.hasMatch()) {
     171         [ +  - ]:           7 :       closeList();
     172         [ +  - ]:           7 :       closeBlockquote();
     173         [ +  - ]:           7 :       closeTable();
     174         [ +  - ]:           7 :       int level = headerMatch.captured(1).length();
     175   [ +  -  +  - ]:           7 :       QString content = processInline(headerMatch.captured(2));
     176   [ +  -  +  -  :          21 :       html += QStringLiteral("<h%1 style='margin:8px 0 4px 0;'>%2</h%1>\n").arg(level).arg(content);
                   +  - ]
     177                 :           7 :       continue;
     178                 :           7 :     }
     179                 :             : 
     180                 :             :     // ── Tables ──
     181                 :             :     // Detect table rows: lines starting and containing |
     182                 :             :     static QRegularExpression tableRowRe(
     183   [ +  +  +  -  :         161 :         QStringLiteral("^\\s*\\|(.+)\\|\\s*$"));
             +  -  -  - ]
     184         [ +  - ]:         155 :     auto tableMatch = tableRowRe.match(line);
     185   [ +  -  +  + ]:         155 :     if (tableMatch.hasMatch()) {
     186         [ +  - ]:          10 :       closeList();
     187         [ +  - ]:          10 :       closeBlockquote();
     188                 :             : 
     189         [ +  - ]:          10 :       QString rowContent = tableMatch.captured(1);
     190         [ +  - ]:          10 :       QStringList cells = rowContent.split(QLatin1Char('|'));
     191                 :             : 
     192                 :             :       // Check if this is a separator row (---|---|---):
     193                 :             :       static QRegularExpression sepRe(
     194   [ +  +  +  -  :          13 :           QStringLiteral("^[\\s:]*-{2,}[\\s:]*$"));
             +  -  -  - ]
     195                 :          10 :       bool isSeparator = true;
     196   [ +  -  +  -  :          16 :       for (const QString &cell : cells) {
                   +  + ]
     197   [ +  -  +  -  :          13 :         if (!sepRe.match(cell.trimmed()).hasMatch()) {
             +  -  +  + ]
     198                 :           7 :           isSeparator = false;
     199                 :           7 :           break;
     200                 :             :         }
     201                 :             :       }
     202                 :             : 
     203         [ +  + ]:          10 :       if (isSeparator) {
     204                 :             :         // Skip separator row (styling handled by CSS)
     205                 :           3 :         continue;
     206                 :             :       }
     207                 :             : 
     208         [ +  + ]:           7 :       if (!inTable) {
     209                 :           3 :         inTable = true;
     210                 :           3 :         tableRowIndex = 0;
     211         [ +  - ]:           3 :         html += QStringLiteral(
     212                 :             :             "<table style=\"border-collapse:collapse;width:100%;"
     213                 :             :             "margin:12px 0;\">\n");
     214                 :             :         // (cell borders/backgrounds use @md_table_border / @md_code_bg)
     215                 :             :       }
     216                 :             : 
     217                 :             :       // First row = header, rest = body
     218                 :           7 :       QString tag = (tableRowIndex == 0)
     219                 :           3 :                         ? QStringLiteral("th")
     220   [ +  +  +  +  :          14 :                         : QStringLiteral("td");
                   +  + ]
     221                 :           7 :       QString bgStyle = (tableRowIndex == 0)
     222   [ +  +  -  -  :          10 :                             ? QStringLiteral("background:%1;font-weight:600;")
                   -  - ]
     223   [ +  -  +  +  :          10 :                                   .arg(mdTok("@md_code_bg"))
                   -  - ]
     224   [ +  +  +  -  :          20 :                             : QString();
                   +  + ]
     225                 :             : 
     226         [ +  - ]:           7 :       html += QStringLiteral("<tr>");
     227   [ +  -  +  -  :          21 :       for (const QString &cell : cells) {
                   +  + ]
     228                 :          28 :         html += QStringLiteral(
     229                 :             :                     "<%1 style=\"border:1px solid %2;padding:6px 12px;%3\">")
     230   [ +  -  +  -  :          14 :                     .arg(tag, mdTok("@md_table_border"), bgStyle);
                   +  - ]
     231   [ +  -  +  -  :          14 :         html += processInline(cell.trimmed());
                   +  - ]
     232   [ +  -  +  - ]:          28 :         html += QStringLiteral("</%1>").arg(tag);
     233                 :             :       }
     234         [ +  - ]:           7 :       html += QStringLiteral("</tr>\n");
     235                 :           7 :       tableRowIndex++;
     236                 :           7 :       continue;
     237         [ -  + ]:         155 :     } else if (inTable) {
     238         [ #  # ]:           0 :       closeTable();
     239                 :             :     }
     240                 :             : 
     241                 :             :     // ── Checkboxes ──
     242                 :             :     static QRegularExpression checkboxRe(
     243   [ +  +  +  -  :         151 :         QStringLiteral("^(\\s*)- \\[([ xX])\\]\\s+(.+)$"));
             +  -  -  - ]
     244         [ +  - ]:         145 :     auto cbMatch = checkboxRe.match(line);
     245   [ +  -  +  + ]:         145 :     if (cbMatch.hasMatch()) {
     246         [ +  - ]:          11 :       closeBlockquote();
     247         [ +  - ]:          11 :       closeTable();
     248         [ +  + ]:          11 :       if (!inUl) {
     249         [ +  - ]:           5 :         closeList(); // close any OL
     250         [ +  - ]:           5 :         html += QStringLiteral("<ul style=\"list-style:none;margin:2px 0;padding-left:16px;\">\n");
     251                 :           5 :         inUl = true;
     252                 :             :       }
     253   [ +  -  +  - ]:          11 :       bool checked = cbMatch.captured(2).toLower() == QStringLiteral("x");
     254                 :             :       // Interactive checkbox anchors are emitted only for a caller-provided,
     255                 :             :       // per-render token. Generic untrusted Markdown gets an inert span and
     256                 :             :       // therefore cannot imitate an internal application action.
     257   [ +  +  +  +  :          22 :       QString icon = checked ? QStringLiteral("&#9745;") : QStringLiteral("&#9744;");
                   +  + ]
     258                 :          11 :       QString link;
     259         [ +  + ]:          11 :       if (checkboxActionToken.isEmpty()) {
     260         [ +  - ]:           8 :         link = QStringLiteral("<span>%1</span>").arg(icon);
     261                 :             :       } else {
     262                 :             :         const QString encodedToken = QString::fromLatin1(
     263   [ +  -  +  - ]:           7 :             QUrl::toPercentEncoding(checkboxActionToken));
     264                 :          14 :         link = QStringLiteral(
     265                 :             :             "<a href=\"mailjd-task-toggle:?token=%1&amp;index=%2\" "
     266                 :             :             "style=\"text-decoration:none;cursor:pointer;\">%3</a>")
     267         [ +  - ]:          14 :                    .arg(encodedToken)
     268         [ +  - ]:          14 :                    .arg(checkboxIndex)
     269         [ +  - ]:           7 :                    .arg(icon);
     270         [ +  - ]:           7 :         result.checkboxIndexes.insert(checkboxIndex);
     271                 :           7 :       }
     272                 :          11 :       checkboxIndex++;
     273   [ +  -  +  - ]:          11 :       QString content = processInline(cbMatch.captured(3));
     274         [ +  + ]:          11 :       if (checked) {
     275                 :          10 :         html += QStringLiteral(
     276                 :             :             "<li>%1 <span style=\"text-decoration:line-through;"
     277                 :             :             "color:%2;\">%3</span></li>\n")
     278   [ +  -  +  -  :          10 :                     .arg(link, mdTok("@md_strike"), content);
                   +  - ]
     279                 :             :       } else {
     280   [ +  -  +  - ]:           6 :         html += QStringLiteral("<li>%1 %2</li>\n").arg(link, content);
     281                 :             :       }
     282                 :          11 :       continue;
     283                 :          11 :     }
     284                 :             : 
     285                 :             :     // ── Unordered lists ──
     286                 :             :     static QRegularExpression ulRe(
     287   [ +  +  +  -  :         140 :         QStringLiteral("^(\\s*)[-*+]\\s+(.+)$"));
             +  -  -  - ]
     288         [ +  - ]:         134 :     auto ulMatch = ulRe.match(line);
     289   [ +  -  +  + ]:         134 :     if (ulMatch.hasMatch()) {
     290         [ +  - ]:           5 :       closeBlockquote();
     291         [ +  - ]:           5 :       closeTable();
     292         [ +  + ]:           5 :       if (!inUl) {
     293         [ +  - ]:           2 :         closeList();
     294         [ +  - ]:           2 :         html += QStringLiteral("<ul style='margin:2px 0;padding-left:20px;'>\n");
     295                 :           2 :         inUl = true;
     296                 :             :       }
     297                 :          10 :       html += QStringLiteral("<li>%1</li>\n")
     298   [ +  -  +  -  :           5 :                   .arg(processInline(ulMatch.captured(2)));
             +  -  +  - ]
     299                 :           5 :       continue;
     300                 :             :     }
     301                 :             : 
     302                 :             :     // ── Ordered lists ──
     303                 :             :     static QRegularExpression olRe(
     304   [ +  +  +  -  :         135 :         QStringLiteral("^(\\s*)\\d+\\.\\s+(.+)$"));
             +  -  -  - ]
     305         [ +  - ]:         129 :     auto olMatch = olRe.match(line);
     306   [ +  -  +  + ]:         129 :     if (olMatch.hasMatch()) {
     307         [ +  - ]:           3 :       closeBlockquote();
     308         [ +  - ]:           3 :       closeTable();
     309         [ +  + ]:           3 :       if (!inOl) {
     310         [ +  - ]:           1 :         closeList();
     311         [ +  - ]:           1 :         html += QStringLiteral("<ol style='margin:2px 0;padding-left:20px;'>\n");
     312                 :           1 :         inOl = true;
     313                 :             :       }
     314                 :           6 :       html += QStringLiteral("<li>%1</li>\n")
     315   [ +  -  +  -  :           3 :                   .arg(processInline(olMatch.captured(2)));
             +  -  +  - ]
     316                 :           3 :       continue;
     317                 :             :     }
     318                 :             : 
     319                 :             :     // Close lists if not a list line
     320   [ +  +  -  + ]:         126 :     if (inUl || inOl)
     321         [ +  - ]:           1 :       closeList();
     322                 :             : 
     323                 :             :     // ── Blockquotes ──
     324                 :             :     static QRegularExpression bqRe(
     325   [ +  +  +  -  :         132 :         QStringLiteral("^&gt;\\s?(.*)$"));
             +  -  -  - ]
     326         [ +  - ]:         126 :     auto bqMatch = bqRe.match(line);
     327   [ +  -  +  + ]:         126 :     if (bqMatch.hasMatch()) {
     328         [ +  - ]:           4 :       closeTable();
     329         [ +  - ]:           4 :       if (!inBlockquote) {
     330                 :           8 :         html += QStringLiteral(
     331                 :             :                     "<blockquote style=\"border-left:4px solid %1;"
     332                 :             :                     "margin:8px 0;padding:4px 16px;color:%2;"
     333                 :             :                     "background:%3;border-radius:0 4px 4px 0;\">\n")
     334   [ +  -  +  -  :           8 :                     .arg(mdTok("@md_quote_border"), mdTok("@md_quote_fg"),
                   +  - ]
     335   [ +  -  +  - ]:          12 :                          mdTok("@md_quote_bg"));
     336                 :           4 :         inBlockquote = true;
     337                 :             :       }
     338   [ +  -  +  -  :           4 :       html += processInline(bqMatch.captured(1));
                   +  - ]
     339         [ +  - ]:           4 :       html += QStringLiteral("<br>\n");
     340                 :           4 :       continue;
     341         [ +  + ]:         122 :     } else if (inBlockquote) {
     342         [ +  - ]:           1 :       closeBlockquote();
     343                 :             :     }
     344                 :             : 
     345                 :             :     // ── Empty lines ──
     346   [ +  -  +  + ]:         122 :     if (line.trimmed().isEmpty()) {
     347         [ +  - ]:           5 :       html += QStringLiteral("<div style='height:8px;'></div>\n");
     348                 :           5 :       continue;
     349                 :             :     }
     350                 :             : 
     351                 :             :     // ── Regular paragraph text ──
     352         [ +  - ]:         117 :     html += QStringLiteral("<p style='margin:0;padding:0;'>");
     353   [ +  -  +  - ]:         117 :     html += processInline(line);
     354         [ +  - ]:         117 :     html += QStringLiteral("</p>\n");
     355   [ +  +  +  +  :         313 :   }
          +  +  +  +  +  
             +  +  +  +  
                      + ]
     356                 :             : 
     357                 :             :   // Close any open blocks
     358         [ -  + ]:         129 :   if (inCodeBlock)
     359         [ #  # ]:           0 :     html += QStringLiteral("</code></pre>\n");
     360         [ +  - ]:         129 :   closeList();
     361         [ +  - ]:         129 :   closeBlockquote();
     362         [ +  - ]:         129 :   closeTable();
     363                 :             : 
     364                 :         129 :   result.html = html;
     365                 :         129 :   return result;
     366                 :         129 : }
        

Generated by: LCOV version 2.0-1