diff options
| -rw-r--r-- | CMakeLists.txt | 17 | ||||
| -rw-r--r-- | src/CMakeLists.txt | 4 | ||||
| -rw-r--r-- | src/markdownrenderer.cpp | 92 | ||||
| -rw-r--r-- | src/markdownrenderer.h | 38 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_markdownrenderer.cpp | 121 |
6 files changed, 272 insertions, 1 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 97a1d90..47b0df6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,6 +34,23 @@ message(STATUS "Found notmuch: ${NOTMUCH_LIBRARY}") find_package(PkgConfig REQUIRED) pkg_check_modules(GMIME REQUIRED IMPORTED_TARGET gmime-3.0) +# cmark-gfm renders the composer's markdown body into the HTML part. +# +# TWO lookups, not one, and this is the trap: only the CORE library ships a +# pkg-config file. `libcmark-gfm-extensions` has none (verified 2026-08-20 on +# Slackware, cmark-gfm-0.29.0.gfm.13), so it is located by hand exactly as +# notmuch is. The extensions library is not optional here: autolink, +# strikethrough and tasklist all live in it, and without it a bare URL in a +# mail body is not a link. +pkg_check_modules(CMARK_GFM REQUIRED IMPORTED_TARGET libcmark-gfm) +find_library(CMARK_GFM_EXTENSIONS_LIBRARY NAMES cmark-gfm-extensions) +if(NOT CMARK_GFM_EXTENSIONS_LIBRARY) + message(FATAL_ERROR + "libcmark-gfm-extensions not found. It ships with cmark-gfm but has " + "no pkg-config file; it provides autolink, strikethrough and tasklist.") +endif() +message(STATUS "Found cmark-gfm extensions: ${CMARK_GFM_EXTENSIONS_LIBRARY}") + # The version lives only in the project() call above; version.h is generated # from it so no source file repeats the literal. configure_file( diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b63ff3e..6108696 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,6 +2,7 @@ add_library(qtmaildir_lib STATIC keymap.cpp config.cpp mimeparser.cpp + markdownrenderer.cpp requestinterceptor.cpp htmlbuilder.cpp cidschemehandler.cpp @@ -36,7 +37,8 @@ target_include_directories(qtmaildir_lib target_link_libraries(qtmaildir_lib PUBLIC Qt6::Widgets Qt6::Svg Qt6::WebEngineWidgets PkgConfig::GMIME - ${NOTMUCH_LIBRARY}) + ${NOTMUCH_LIBRARY} PkgConfig::CMARK_GFM + ${CMARK_GFM_EXTENSIONS_LIBRARY}) # resources.qrc belongs to the executable, not to the static library. A qrc # compiled into a .a registers itself from a global initialiser, and the linker diff --git a/src/markdownrenderer.cpp b/src/markdownrenderer.cpp new file mode 100644 index 0000000..a9b8177 --- /dev/null +++ b/src/markdownrenderer.cpp @@ -0,0 +1,92 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +// cmark-gfm's headers are C and carry no Qt interaction, so the gmime +// include-order rule does not apply here. They still go first, for consistency +// with mimeparser.cpp. +#include <cmark-gfm.h> +#include <cmark-gfm-core-extensions.h> + +#include "markdownrenderer.h" + +#include <QByteArray> + +#include <cstdlib> + +namespace { + +/// The extensions this application enables, by cmark-gfm's own names. +/// +/// `table` is absent deliberately, not by oversight: tables render badly +/// across mail clients regardless of who generates them. `tagfilter` is absent +/// because CMARK_OPT_SAFE already suppresses raw HTML wholesale, which is the +/// stronger measure. +const char *const kExtensions[] = { "autolink", "strikethrough", "tasklist" }; + +} // namespace + +QString MarkdownRenderer::toHtml(const QString &markdown) +{ + if (markdown.isEmpty()) + return {}; + + // Idempotent and required before cmark_find_syntax_extension() can resolve + // any name. Calling it per render rather than once at startup keeps this + // function free of initialisation order concerns; it is a hash lookup + // after the first call. + cmark_gfm_core_extensions_ensure_registered(); + + // SAFE suppresses raw HTML in the INPUT. It does not escape the output, + // which is markup by definition. + const int options = CMARK_OPT_DEFAULT | CMARK_OPT_SAFE; + + cmark_parser *parser = cmark_parser_new(options); + if (!parser) + return {}; + + for (const char *name : kExtensions) { + // A missing extension is a broken installation rather than a + // condition to handle: the library was found by CMake. Skipping it + // degrades to plain CommonMark rather than crashing. + if (cmark_syntax_extension *extension = cmark_find_syntax_extension(name)) + cmark_parser_attach_syntax_extension(parser, extension); + } + + const QByteArray utf8 = markdown.toUtf8(); + cmark_parser_feed(parser, utf8.constData(), static_cast<size_t>(utf8.size())); + + cmark_node *document = cmark_parser_finish(parser); + if (!document) { + cmark_parser_free(parser); + return {}; + } + + // The extension list must be passed to the renderer as well as to the + // parser. Passing nullptr here parses the tasklist correctly and then + // renders it as a plain list item, which looks like the extension never + // worked. + char *html = cmark_render_html(document, options, + cmark_parser_get_syntax_extensions(parser)); + const QString result = html ? QString::fromUtf8(html) : QString(); + + free(html); + cmark_node_free(document); + cmark_parser_free(parser); + + return result; +} diff --git a/src/markdownrenderer.h b/src/markdownrenderer.h new file mode 100644 index 0000000..80c52bf --- /dev/null +++ b/src/markdownrenderer.h @@ -0,0 +1,38 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#pragma once + +#include <QString> + +/// Renders the composer's markdown body into the HTML part's fragment. +/// +/// A namespace of free functions rather than a class: there is no state, and +/// keeping it painter-free and widget-free is what lets the extension +/// configuration be tested on its own. `MessageBuilder` calls this; nothing +/// else does. +namespace MarkdownRenderer { + +/// The markdown source as an HTML fragment: no <html>, <head> or <body>. +/// +/// Three extensions are enabled (autolink, strikethrough, tasklist) and +/// tables are deliberately not. Raw HTML in the input is suppressed by +/// CMARK_OPT_SAFE. +QString toHtml(const QString &markdown); + +} // namespace MarkdownRenderer diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d1d8a29..9f6e851 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -74,3 +74,4 @@ add_qtmaildir_test(translations) # only as English in a running Italian UI. target_compile_definitions(test_translations PRIVATE TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/translations") +add_qtmaildir_test(markdownrenderer) diff --git a/tests/test_markdownrenderer.cpp b/tests/test_markdownrenderer.cpp new file mode 100644 index 0000000..44f3ccb --- /dev/null +++ b/tests/test_markdownrenderer.cpp @@ -0,0 +1,121 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include <QtTest> + +#include "markdownrenderer.h" + +/// The extension configuration cmark-gfm renders the composer's body with. +/// +/// No QApplication is needed here: MarkdownRenderer is a pure function over +/// strings, so QTEST_APPLESS_MAIN avoids pulling in a platform plugin for a +/// test that has nothing to do with widgets. +class TestMarkdownRenderer : public QObject +{ + Q_OBJECT +private slots: + void commonMarkBasicsRender(); + void autolinkTurnsABareUrlIntoALink(); + void strikethroughRenders(); + void tasklistRenders(); + void tablesAreNotEnabled(); + void rawHtmlIsSuppressed(); + void accentedTextSurvivesAsUtf8(); + void emptyInputProducesEmptyOutput(); +}; + +void TestMarkdownRenderer::commonMarkBasicsRender() +{ + const QString html = MarkdownRenderer::toHtml( + QStringLiteral("**bold** *italic* `code`")); + QVERIFY2(html.contains(QStringLiteral("<strong>")), qPrintable(html)); + QVERIFY2(html.contains(QStringLiteral("<em>")), qPrintable(html)); + QVERIFY2(html.contains(QStringLiteral("<code>")), qPrintable(html)); +} + +void TestMarkdownRenderer::autolinkTurnsABareUrlIntoALink() +{ + // The whole reason cmark-gfm was chosen over plain cmark. Under + // CommonMark a bare URL is text, and a bare URL in mail is expected to + // be clickable. + const QString html = MarkdownRenderer::toHtml( + QStringLiteral("see https://example.org for details")); + QVERIFY2(html.contains(QStringLiteral("<a href=\"https://example.org\"")), + qPrintable(html)); +} + +void TestMarkdownRenderer::strikethroughRenders() +{ + const QString html = MarkdownRenderer::toHtml(QStringLiteral("~~gone~~")); + QVERIFY2(html.contains(QStringLiteral("<del>gone</del>")), qPrintable(html)); +} + +void TestMarkdownRenderer::tasklistRenders() +{ + // Known ceiling: many mail clients strip the checkbox, so those + // recipients see the item with no marker. The plain part still carries + // the literal "- [ ]", so nothing is lost, only the HTML rendering. + const QString html = MarkdownRenderer::toHtml( + QStringLiteral("- [ ] todo\n- [x] done")); + QVERIFY2(html.contains(QStringLiteral("type=\"checkbox\"")), qPrintable(html)); + QVERIFY2(html.contains(QStringLiteral("checked")), qPrintable(html)); +} + +void TestMarkdownRenderer::tablesAreNotEnabled() +{ + // Deliberately off: tables render badly across mail clients regardless of + // who generates them. The extension EXISTS in the library, so this + // asserts a decision rather than a limitation, and would silently start + // passing the wrong way if someone attached it "for completeness". + const QString html = MarkdownRenderer::toHtml( + QStringLiteral("| a | b |\n|---|---|\n| 1 | 2 |")); + QVERIFY2(!html.contains(QStringLiteral("<table")), qPrintable(html)); + QVERIFY2(html.contains(QStringLiteral("| a | b |")), qPrintable(html)); +} + +void TestMarkdownRenderer::rawHtmlIsSuppressed() +{ + // CMARK_OPT_SAFE. The body is the user's own text, but a body that can + // inject markup into its own generated HTML part is a sharp edge with no + // upside. + const QString html = MarkdownRenderer::toHtml( + QStringLiteral("<script>alert(1)</script>\n\nafter")); + QVERIFY2(!html.contains(QStringLiteral("<script>")), qPrintable(html)); + QVERIFY2(html.contains(QStringLiteral("after")), qPrintable(html)); +} + +void TestMarkdownRenderer::accentedTextSurvivesAsUtf8() +{ + // This user writes Italian, so accented text is every message rather + // than an edge case, and a UTF-8 round trip through a C library is + // exactly where it would be lost. + const QString source = QString::fromUtf8("perch\xC3\xA9 \xC3\xA8 cos\xC3\xAC"); + const QString html = MarkdownRenderer::toHtml(source); + QVERIFY2(html.contains(source), qPrintable(html)); +} + +void TestMarkdownRenderer::emptyInputProducesEmptyOutput() +{ + // reply_no_quote opens a composer with an empty body and it must not + // produce a stray paragraph or crash the renderer. + const QString html = MarkdownRenderer::toHtml(QString()); + QVERIFY2(html.trimmed().isEmpty(), qPrintable(html)); +} + +QTEST_APPLESS_MAIN(TestMarkdownRenderer) +#include "test_markdownrenderer.moc" |
