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.6 % 210 205
Test Date: 2026-06-21 21:10:19 Functions: 100.0 % 7 7
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 58.0 % 486 282

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

Generated by: LCOV version 2.0-1