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