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("&"));
23 [ + - ]: 169 : out.replace(QLatin1Char('<'), QStringLiteral("<"));
24 [ + - ]: 169 : out.replace(QLatin1Char('>'), QStringLiteral(">"));
25 [ + - ]: 169 : out.replace(QLatin1Char('"'), QStringLiteral("""));
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("☑") : QStringLiteral("☐");
+ + ]
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&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("^>\\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 : }
|