// Copyright (C) 2019 The Qt Company Ltd. // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only #include #include #include #include #include #include #include #include #include #include #include Q_LOGGING_CATEGORY(lcTests, "qt.text.tests") // #define DEBUG_WRITE_OUTPUT class tst_QTextMarkdownWriter : public QObject { Q_OBJECT public slots: void init(); void cleanup(); private slots: void testWriteParagraph_data(); void testWriteParagraph(); void testWriteList(); void testWriteEmptyList(); void testWriteCheckboxListItemEndingWithCode(); void testWriteNestedBulletLists_data(); void testWriteNestedBulletLists(); void testWriteNestedNumericLists(); void testWriteNumericListWithStart(); void testWriteTable(); void frontMatter(); void charFormatWrapping_data(); void charFormatWrapping(); void charFormat_data(); void charFormat(); void rewriteDocument_data(); void rewriteDocument(); void fromHtml_data(); void fromHtml(); void fromPlainTextAndBack_data(); void fromPlainTextAndBack(); void escapeSpecialCharacters_data(); void escapeSpecialCharacters(); private: bool isMainFontFixed(); bool isFixedFontProportional(); QString documentToUnixMarkdown(); private: QTextDocument *document; QFont m_monoFont = QFontDatabase::systemFont(QFontDatabase::FixedFont); QFont m_defaultFont; }; void tst_QTextMarkdownWriter::init() { document = new QTextDocument(); } void tst_QTextMarkdownWriter::cleanup() { delete document; } bool tst_QTextMarkdownWriter::isMainFontFixed() { bool ret = QFontInfo(QGuiApplication::font()).fixedPitch(); if (ret) { qCWarning(lcTests) << "QFontDatabase::GeneralFont is monospaced: markdown writing is likely to use too many backticks" << QFontDatabase::systemFont(QFontDatabase::GeneralFont); } return ret; } bool tst_QTextMarkdownWriter::isFixedFontProportional() { bool ret = !QFontInfo(QFontDatabase::systemFont(QFontDatabase::FixedFont)).fixedPitch(); if (ret) { qCWarning(lcTests) << "QFontDatabase::FixedFont is NOT monospaced: markdown writing is likely to use too few backticks" << QFontDatabase::systemFont(QFontDatabase::FixedFont); } return ret; } QString tst_QTextMarkdownWriter::documentToUnixMarkdown() { QString ret; QTextStream ts(&ret, QIODevice::WriteOnly); QTextMarkdownWriter writer(ts, QTextDocument::MarkdownDialectGitHub); writer.writeAll(document); return ret; } void tst_QTextMarkdownWriter::testWriteParagraph_data() { QTest::addColumn("input"); QTest::addColumn("expectedOutput"); QTest::newRow("empty") << "" << ""; QTest::newRow("spaces") << "foobar word" << "foobar word\n\n"; QTest::newRow("starting spaces") << " starting spaces" << " starting spaces\n\n"; QTest::newRow("trailing spaces") << "trailing spaces " << "trailing spaces \n\n"; QTest::newRow("tab") << "word\ttab x" << "word\ttab x\n\n"; QTest::newRow("tab2") << "word\t\ttab\tx" << "word\t\ttab\tx\n\n"; QTest::newRow("misc") << "foobar word\ttab x" << "foobar word\ttab x\n\n"; QTest::newRow("misc2") << "\t \tFoo" << "\t \tFoo\n\n"; } void tst_QTextMarkdownWriter::testWriteParagraph() { QFETCH(QString, input); QFETCH(QString, expectedOutput); QTextCursor cursor(document); cursor.insertText(input); const QString output = documentToUnixMarkdown(); if (output != expectedOutput && isMainFontFixed()) QEXPECT_FAIL("", "fixed-pitch main font (QTBUG-103484)", Continue); QCOMPARE(output, expectedOutput); } void tst_QTextMarkdownWriter::testWriteList() { QTextCursor cursor(document); QTextList *list = cursor.createList(QTextListFormat::ListDisc); cursor.insertText("ListItem 1"); list->add(cursor.block()); cursor.insertBlock(); cursor.insertText("ListItem 2"); list->add(cursor.block()); const QString output = documentToUnixMarkdown(); const QString expected = QString::fromLatin1("- ListItem 1\n- ListItem 2\n"); if (output != expected && isMainFontFixed()) QEXPECT_FAIL("", "fixed-pitch main font (QTBUG-103484)", Continue); QCOMPARE(output, expected); } void tst_QTextMarkdownWriter::testWriteEmptyList() { QTextCursor cursor(document); cursor.createList(QTextListFormat::ListDisc); QCOMPARE(documentToUnixMarkdown(), QString::fromLatin1("- \n")); } void tst_QTextMarkdownWriter::testWriteCheckboxListItemEndingWithCode() { QTextCursor cursor(document); QTextList *list = cursor.createList(QTextListFormat::ListDisc); cursor.insertText("Image.originalSize property (not necessary; PdfDocument.pagePointSize() substitutes)"); list->add(cursor.block()); { auto fmt = cursor.block().blockFormat(); fmt.setMarker(QTextBlockFormat::MarkerType::Unchecked); cursor.setBlockFormat(fmt); } cursor.movePosition(QTextCursor::PreviousWord, QTextCursor::MoveAnchor, 2); cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor); cursor.movePosition(QTextCursor::PreviousWord, QTextCursor::KeepAnchor, 4); QCOMPARE(cursor.selectedText(), QString::fromLatin1("PdfDocument.pagePointSize()")); auto fmt = cursor.charFormat(); fmt.setFontFixedPitch(true); cursor.setCharFormat(fmt); cursor.movePosition(QTextCursor::PreviousWord, QTextCursor::MoveAnchor, 5); cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor); cursor.movePosition(QTextCursor::PreviousWord, QTextCursor::KeepAnchor, 4); QCOMPARE(cursor.selectedText(), QString::fromLatin1("Image.originalSize")); cursor.setCharFormat(fmt); const QString output = documentToUnixMarkdown(); const QString expected = QString::fromLatin1( "- [ ] `Image.originalSize` property (not necessary; `PdfDocument.pagePointSize()`\n substitutes)\n"); if (output != expected && isMainFontFixed()) QEXPECT_FAIL("", "fixed-pitch main font (QTBUG-103484)", Continue); QCOMPARE(output, expected); } void tst_QTextMarkdownWriter::testWriteNestedBulletLists_data() { QTest::addColumn("checkbox"); QTest::addColumn("checked"); QTest::addColumn("continuationLine"); QTest::addColumn("continuationParagraph"); QTest::addColumn("expectedOutput"); QTest::newRow("plain bullets") << false << false << false << false << "- ListItem 1\n * ListItem 2\n + ListItem 3\n- ListItem 4\n * ListItem 5\n"; QTest::newRow("bullets with continuation lines") << false << false << true << false << "- ListItem 1\n * ListItem 2\n + ListItem 3 with text that won't fit on one line and thus needs a\n continuation\n- ListItem 4\n * ListItem 5 with text that won't fit on one line and thus needs a\n continuation\n"; QTest::newRow("bullets with continuation paragraphs") << false << false << false << true << "- ListItem 1\n\n * ListItem 2\n + ListItem 3\n\n continuation\n\n- ListItem 4\n\n * ListItem 5\n\n continuation\n\n"; QTest::newRow("unchecked") << true << false << false << false << "- [ ] ListItem 1\n * [ ] ListItem 2\n + [ ] ListItem 3\n- [ ] ListItem 4\n * [ ] ListItem 5\n"; QTest::newRow("checked") << true << true << false << false << "- [x] ListItem 1\n * [x] ListItem 2\n + [x] ListItem 3\n- [x] ListItem 4\n * [x] ListItem 5\n"; QTest::newRow("checked with continuation lines") << true << true << true << false << "- [x] ListItem 1\n * [x] ListItem 2\n + [x] ListItem 3 with text that won't fit on one line and thus needs a\n continuation\n- [x] ListItem 4\n * [x] ListItem 5 with text that won't fit on one line and thus needs a\n continuation\n"; QTest::newRow("checked with continuation paragraphs") << true << true << false << true << "- [x] ListItem 1\n\n * [x] ListItem 2\n + [x] ListItem 3\n\n continuation\n\n- [x] ListItem 4\n\n * [x] ListItem 5\n\n continuation\n\n"; } void tst_QTextMarkdownWriter::testWriteNestedBulletLists() { QFETCH(bool, checkbox); QFETCH(bool, checked); QFETCH(bool, continuationParagraph); QFETCH(bool, continuationLine); QFETCH(QString, expectedOutput); QTextCursor cursor(document); QTextBlockFormat blockFmt = cursor.blockFormat(); if (checkbox) { blockFmt.setMarker(checked ? QTextBlockFormat::MarkerType::Checked : QTextBlockFormat::MarkerType::Unchecked); cursor.setBlockFormat(blockFmt); } QTextList *list1 = cursor.createList(QTextListFormat::ListDisc); cursor.insertText("ListItem 1"); list1->add(cursor.block()); QTextListFormat fmt2; fmt2.setStyle(QTextListFormat::ListCircle); fmt2.setIndent(2); QTextList *list2 = cursor.insertList(fmt2); cursor.insertText("ListItem 2"); QTextListFormat fmt3; fmt3.setStyle(QTextListFormat::ListSquare); fmt3.setIndent(3); cursor.insertList(fmt3); cursor.insertText(continuationLine ? "ListItem 3 with text that won't fit on one line and thus needs a continuation" : "ListItem 3"); if (continuationParagraph) { QTextBlockFormat blockFmt; blockFmt.setIndent(2); cursor.insertBlock(blockFmt); cursor.insertText("continuation"); } cursor.insertBlock(blockFmt); cursor.insertText("ListItem 4"); list1->add(cursor.block()); cursor.insertBlock(); cursor.insertText(continuationLine ? "ListItem 5 with text that won't fit on one line and thus needs a continuation" : "ListItem 5"); list2->add(cursor.block()); if (continuationParagraph) { QTextBlockFormat blockFmt; blockFmt.setIndent(2); cursor.insertBlock(blockFmt); cursor.insertText("continuation"); } const QString output = documentToUnixMarkdown(); #ifdef DEBUG_WRITE_OUTPUT { QFile out("/tmp/" + QLatin1String(QTest::currentDataTag()) + ".md"); out.open(QFile::WriteOnly); out.write(output.toUtf8()); out.close(); } #endif if (output != expectedOutput && isMainFontFixed()) QEXPECT_FAIL("", "fixed-pitch main font (QTBUG-103484)", Continue); QCOMPARE(output, expectedOutput); } void tst_QTextMarkdownWriter::testWriteNestedNumericLists() { QTextCursor cursor(document); QTextList *list1 = cursor.createList(QTextListFormat::ListDecimal); cursor.insertText("ListItem 1"); list1->add(cursor.block()); QTextListFormat fmt2; // Alpha "numbering" is not supported in markdown, so we'll actually get decimal. fmt2.setStyle(QTextListFormat::ListLowerAlpha); fmt2.setNumberSuffix(QLatin1String(")")); fmt2.setIndent(2); QTextList *list2 = cursor.insertList(fmt2); cursor.insertText("ListItem 2"); QTextListFormat fmt3; fmt3.setStyle(QTextListFormat::ListDecimal); fmt3.setIndent(3); cursor.insertList(fmt3); cursor.insertText("ListItem 3"); cursor.insertBlock(); cursor.insertText("ListItem 4"); list1->add(cursor.block()); cursor.insertBlock(); cursor.insertText("ListItem 5"); list2->add(cursor.block()); const QString output = documentToUnixMarkdown(); #ifdef DEBUG_WRITE_OUTPUT { QFile out(QDir::temp().filePath(QLatin1String(QTest::currentTestFunction()) + ".md")); out.open(QFile::WriteOnly); out.write(output.toUtf8()); out.close(); } { QFile out(QDir::temp().filePath(QLatin1String(QTest::currentTestFunction()) + ".html")); out.open(QFile::WriteOnly); out.write(document->toHtml().toUtf8()); out.close(); } #endif // While we can set the start index for a block, if list items intersect each other, they will // still use the list numbering. const QString expected = QString::fromLatin1( "1. ListItem 1\n 1) ListItem 2\n 1. ListItem 3\n2. ListItem 4\n 2) ListItem 5\n"); if (output != expected && isMainFontFixed()) QEXPECT_FAIL("", "fixed-pitch main font (QTBUG-103484)", Continue); QCOMPARE(output, expected); } void tst_QTextMarkdownWriter::testWriteNumericListWithStart() { QTextCursor cursor(document); // The first list will start at 2. QTextListFormat fmt1; fmt1.setStyle(QTextListFormat::ListDecimal); fmt1.setStart(2); QTextList *list1 = cursor.createList(fmt1); cursor.insertText("ListItem 1"); list1->add(cursor.block()); // This list uses the default start (1) again. QTextListFormat fmt2; // Alpha "numbering" is not supported in markdown, so we'll actually get decimal. fmt2.setStyle(QTextListFormat::ListLowerAlpha); fmt2.setNumberSuffix(QLatin1String(")")); fmt2.setIndent(2); QTextList *list2 = cursor.insertList(fmt2); cursor.insertText("ListItem 2"); // Negative list numbers are disallowed by most Markdown implementations. This list will start // at 1 for that reason. QTextListFormat fmt3; fmt3.setStyle(QTextListFormat::ListDecimal); fmt3.setIndent(3); fmt3.setStart(-1); cursor.insertList(fmt3); cursor.insertText("ListItem 3"); // Continuing list1, so the second item will have the number 3. cursor.insertBlock(); cursor.insertText("ListItem 4"); list1->add(cursor.block()); // This will look out of place: it's in a different position than its list would suggest. // Generates invalid markdown numbering (OK for humans, but md4c will parse it differently than we "meant"). // TODO QTBUG-111707: the writer needs to add newlines, otherwise ListItem 5 becomes part of the text for ListItem 4. cursor.insertBlock(); cursor.insertText("ListItem 5"); list2->add(cursor.block()); // 0 indexed lists are fine. QTextListFormat fmt4; fmt4.setStyle(QTextListFormat::ListDecimal); fmt4.setStart(0); QTextList *list4 = cursor.insertList(fmt4); cursor.insertText("SecondList Item 0"); list4->add(cursor.block()); // Ensure list numbers are incremented properly. cursor.insertBlock(); cursor.insertText("SecondList Item 1"); list4->add(cursor.block()); const QString output = documentToUnixMarkdown(); const QString expected = QString::fromLatin1( R"(2. ListItem 1 1) ListItem 2 1. ListItem 3 3. ListItem 4 2) ListItem 5 0. SecondList Item 0 1. SecondList Item 1 )"); #ifdef DEBUG_WRITE_OUTPUT { QFile out(QDir::temp().filePath(QLatin1String(QTest::currentTestFunction()) + ".md")); out.open(QFile::WriteOnly); out.write(output.toUtf8()); out.close(); } { QFile out(QDir::temp().filePath(QLatin1String(QTest::currentTestFunction()) + ".html")); out.open(QFile::WriteOnly); out.write(document->toHtml().toUtf8()); out.close(); } #endif if (output != expected && isMainFontFixed()) QEXPECT_FAIL("", "fixed-pitch main font (QTBUG-103484)", Continue); QCOMPARE(output, expected); } void tst_QTextMarkdownWriter::testWriteTable() { QTextCursor cursor(document); QTextTable * table = cursor.insertTable(4, 3); cursor = table->cellAt(0, 0).firstCursorPosition(); // valid Markdown tables need headers, but QTextTable doesn't make that distinction // so QTextMarkdownWriter assumes the first row of any table is a header cursor.insertText("one"); cursor.movePosition(QTextCursor::NextCell); cursor.insertText("two"); cursor.movePosition(QTextCursor::NextCell); cursor.insertText("three"); cursor.movePosition(QTextCursor::NextCell); cursor.insertText("alice"); cursor.movePosition(QTextCursor::NextCell); cursor.insertText("bob"); cursor.movePosition(QTextCursor::NextCell); cursor.insertText("carl"); cursor.movePosition(QTextCursor::NextCell); cursor.insertText("dennis"); cursor.movePosition(QTextCursor::NextCell); cursor.insertText("eric"); cursor.movePosition(QTextCursor::NextCell); cursor.insertText("fiona"); cursor.movePosition(QTextCursor::NextCell); cursor.insertText("gina"); /* |one |two |three| |------|----|-----| |alice |bob |carl | |dennis|eric|fiona| |gina | | | */ QString md = documentToUnixMarkdown(); #ifdef DEBUG_WRITE_OUTPUT { QFile out("/tmp/table.md"); out.open(QFile::WriteOnly); out.write(md.toUtf8()); out.close(); } #endif QString expected = QString::fromLatin1( "\n|one |two |three|\n|------|----|-----|\n|alice |bob |carl |\n|dennis|eric|fiona|\n|gina | | |\n\n"); if (md != expected && isMainFontFixed()) QEXPECT_FAIL("", "fixed-pitch main font (QTBUG-103484)", Continue); QCOMPARE(md, expected); // create table with merged cells document->clear(); cursor = QTextCursor(document); table = cursor.insertTable(3, 3); table->mergeCells(0, 0, 1, 2); table->mergeCells(1, 1, 1, 2); cursor = table->cellAt(0, 0).firstCursorPosition(); cursor.insertText("a"); cursor.movePosition(QTextCursor::NextCell); cursor.insertText("b"); cursor.movePosition(QTextCursor::NextCell); cursor.insertText("c"); cursor.movePosition(QTextCursor::NextCell); cursor.insertText("d"); cursor.movePosition(QTextCursor::NextCell); cursor.insertText("e"); cursor.movePosition(QTextCursor::NextCell); cursor.insertText("f"); /* +---+-+ |a |b| +---+-+ |c| d| +-+-+-+ |e|f| | +-+-+-+ generates |a ||b| |-|-|-| |c|d || |e|f| | */ md = documentToUnixMarkdown(); #ifdef DEBUG_WRITE_OUTPUT { QFile out("/tmp/table-merged-cells.md"); out.open(QFile::WriteOnly); out.write(md.toUtf8()); out.close(); } #endif expected = QString::fromLatin1("\n|a ||b|\n|-|-|-|\n|c|d ||\n|e|f| |\n\n"); if (md != expected && isMainFontFixed()) QEXPECT_FAIL("", "fixed-pitch main font (QTBUG-103484)", Continue); QCOMPARE(md, expected); } void tst_QTextMarkdownWriter::frontMatter() { QTextCursor cursor(document); cursor.insertText("bar"); document->setMetaInformation(QTextDocument::FrontMatter, "foo"); const QString output = documentToUnixMarkdown(); const QString expectedOutput("---\nfoo\n---\nbar\n\n"); if (output != expectedOutput && isMainFontFixed()) QEXPECT_FAIL("", "fixed-pitch main font (QTBUG-103484)", Continue); QCOMPARE(output, expectedOutput); } void tst_QTextMarkdownWriter::charFormatWrapping_data() { QTest::addColumn("property"); QTest::addColumn("propertyValue"); QTest::addColumn("followingText"); QTest::addColumn("expectedIndicator"); const QString spaced = " after"; const QString unspaced = ", and some more after"; QTest::newRow("FontFixedPitch-spaced") << QTextFormat::FontFixedPitch << QVariant(true) << spaced << "`"; QTest::newRow("FontFixedPitch-unspaced") << QTextFormat::FontFixedPitch << QVariant(true) << unspaced << "`"; QTest::newRow("FontItalic") << QTextFormat::FontItalic << QVariant(true) << spaced << "*"; QTest::newRow("FontUnderline") << QTextFormat::FontUnderline << QVariant(true) << spaced << "_"; QTest::newRow("FontStrikeOut") << QTextFormat::FontStrikeOut << QVariant(true) << spaced << "~~"; QTest::newRow("FontWeight-spaced") << QTextFormat::FontWeight << QVariant(700) << spaced << "**"; QTest::newRow("FontWeight-unspaced") << QTextFormat::FontWeight << QVariant(700) << unspaced << "**"; } void tst_QTextMarkdownWriter::charFormatWrapping() // QTBUG-116927 { QFETCH(QTextFormat::Property, property); QFETCH(QVariant, propertyValue); QFETCH(QString, expectedIndicator); QFETCH(QString, followingText); const QString newLine("\n"); QTextCursor cursor(document); cursor.insertText("around sixty-four characters to go before some formatted words "); QTextCharFormat fmt; fmt.setProperty(property, propertyValue); cursor.setCharFormat(fmt); cursor.insertText("formatted text"); cursor.setCharFormat({}); cursor.insertText(followingText); qsizetype lastNewLineIndex = 100; for (int push = 0; push < 10; ++push) { if (push > 0) { cursor.movePosition(QTextCursor::StartOfBlock); cursor.insertText("a"); } const QString output = documentToUnixMarkdown().trimmed(); // get rid of trailing newlines const auto nlIdx = output.indexOf(newLine); qCDebug(lcTests) << "push" << push << ":" << output << "newline @" << nlIdx; // we're always wrapping in this test: expect to find a newline QCOMPARE_GT(nlIdx, 70); // don't expect the newline to be more than one character to the right of where we found it last time // i.e. if we already started breaking in the middle: "`formatted\ntext`", // then we would not expect that prepending one more character would make it go // back to breaking afterwards: "`formatted text`\n" (because then the line becomes longer than necessary) QCOMPARE_LE(nlIdx, lastNewLineIndex + 1); lastNewLineIndex = nlIdx; const QString nextChars = output.sliced(nlIdx + newLine.size(), expectedIndicator.size()); const auto startingIndicatorIdx = output.indexOf(expectedIndicator); // the starting indicator always exists, except in case of font problems on some CI platforms if (startingIndicatorIdx <= 0) QSKIP("starting indicator not found, probably due to platform font problems (QTBUG-103484 etc.)"); const auto endingIndicatorIdx = output.indexOf(expectedIndicator, startingIndicatorIdx + 5); qCDebug(lcTests) << "next chars past newline" << nextChars << "indicators @" << startingIndicatorIdx << endingIndicatorIdx; // the closing indicator must exist QCOMPARE_GT(endingIndicatorIdx, startingIndicatorIdx); // don't start a new line with an ending indicator: // we can have "**formatted\ntext**" or "**formatted text**\n" or "\n**formatted text**" // but not "**formatted text\n**" if (startingIndicatorIdx < nlIdx) QCOMPARE_NE(nextChars, expectedIndicator); } } void tst_QTextMarkdownWriter::charFormat_data() { QTest::addColumn("property"); QTest::addColumn("propertyValue"); QTest::addColumn("explicitFont"); QTest::addColumn("expectedOutput"); const QTextFormat::Property NoProperty = QTextFormat::ObjectIndex; QTest::newRow("FontFixedPitch") << QTextFormat::FontFixedPitch << QVariant(true) << m_defaultFont << "before `formatted` after"; if (!isFixedFontProportional()) { // QTBUG-54623 QTBUG-75649 QTBUG-79900 QTBUG-103484 etc. QTest::newRow("mono font") << NoProperty << QVariant() << m_monoFont << "before `formatted` after"; } { QFont font; font.setItalic(true); QTest::newRow("italic font") << NoProperty << QVariant() << font << "before *formatted* after"; } QTest::newRow("FontItalic") << QTextFormat::FontItalic << QVariant(true) << m_defaultFont << "before *formatted* after"; { QFont font; font.setUnderline(true); QTest::newRow("underline font") << NoProperty << QVariant() << font << "before _formatted_ after"; } QTest::newRow("FontUnderline") << QTextFormat::FontUnderline << QVariant(true) << m_defaultFont << "before _formatted_ after"; { QFont font; font.setStrikeOut(true); QTest::newRow("strikeout font") << NoProperty << QVariant() << font << "before ~~formatted~~ after"; } QTest::newRow("FontStrikeOut") << QTextFormat::FontStrikeOut << QVariant(true) << m_defaultFont << "before ~~formatted~~ after"; { QFont font; font.setBold(true); QTest::newRow("bold font") << NoProperty << QVariant() << font << "before **formatted** after"; } { QFont font; font.setWeight(QFont::Black); QTest::newRow("black font") << NoProperty << QVariant() << font << "before **formatted** after"; } QTest::newRow("FontWeight") << QTextFormat::FontWeight << QVariant(700) << m_defaultFont << "before **formatted** after"; QTest::newRow("AnchorHref") << QTextFormat::AnchorHref << QVariant("linky linky") << m_defaultFont << "before [formatted](linky linky) after"; QTest::newRow("TextToolTip") // no effect without AnchorHref << QTextFormat::TextToolTip << QVariant("such a tool") << m_defaultFont << "before formatted after"; } void tst_QTextMarkdownWriter::charFormat() { if (isMainFontFixed()) QSKIP("QTextMarkdownWriter would generate bogus backticks"); QFETCH(QTextFormat::Property, property); QFETCH(QVariant, propertyValue); QFETCH(QFont, explicitFont); QFETCH(QString, expectedOutput); QTextCursor cursor(document); cursor.insertText("before "); QTextCharFormat fmt; if (explicitFont != m_defaultFont) fmt.setFont(explicitFont); if (property != QTextFormat::ObjectIndex) // != 0 fmt.setProperty(property, propertyValue); if (explicitFont == m_monoFont) { QFontInfo fontInfo(fmt.font()); qCDebug(lcTests) << "mono font" << explicitFont << "fontInfo fixedPitch" << fontInfo.fixedPitch() << "fmt fixedPitch" << fmt.fontFixedPitch(); } cursor.setCharFormat(fmt); cursor.insertText("formatted"); cursor.setCharFormat({}); cursor.insertText(" after"); const QString output = documentToUnixMarkdown(); #ifdef DEBUG_WRITE_OUTPUT { QFile out(QDir::temp().filePath(QLatin1String(QTest::currentDataTag()) + ".md")); out.open(QFile::WriteOnly); out.write(output.toUtf8()); out.close(); } #endif QCOMPARE(output.trimmed(), expectedOutput); } void tst_QTextMarkdownWriter::rewriteDocument_data() { QTest::addColumn("inputFile"); QTest::newRow("block quotes") << "blockquotes.md"; QTest::newRow("block quotes with lists") << "blockquotesWithLists.md"; // QTest::newRow("list item with block quote") << "listItemWithBlockquote.md"; // not supported for now QTest::newRow("example") << "example.md"; QTest::newRow("list items after headings") << "headingsAndLists.md"; QTest::newRow("word wrap") << "wordWrap.md"; QTest::newRow("links") << "links.md"; QTest::newRow("lists and code blocks") << "listsAndCodeBlocks.md"; QTest::newRow("front matter") << "yaml.md"; QTest::newRow("long headings") << "longHeadings.md"; } void tst_QTextMarkdownWriter::rewriteDocument() { QFETCH(QString, inputFile); QTextDocument doc; QFile f(QFINDTESTDATA("data/" + inputFile)); QVERIFY(f.open(QFile::ReadOnly | QIODevice::Text)); QString orig = QString::fromUtf8(f.readAll()); f.close(); doc.setMarkdown(orig); QString md = doc.toMarkdown(); #ifdef DEBUG_WRITE_OUTPUT QFile out("/tmp/rewrite-" + inputFile); out.open(QFile::WriteOnly); out.write(md.toUtf8()); out.close(); #endif if (md != orig && isMainFontFixed()) QEXPECT_FAIL("", "fixed-pitch main font (QTBUG-103484)", Continue); QCOMPARE(md, orig); } void tst_QTextMarkdownWriter::fromHtml_data() { QTest::addColumn("input"); QTest::addColumn("expectedOutput"); QTest::newRow("long URL") << "https://www.example.com/dir/subdir/subsubdir/subsubsubdir/subsubsubsubdir/subsubsubsubsubdir/" << "\n*https://www.example.com/dir/subdir/subsubdir/subsubsubdir/subsubsubsubdir/subsubsubsubsubdir/*\n\n"; QTest::newRow("non-emphasis inline asterisk") << "3 * 4" << "3 * 4\n\n"; QTest::newRow("arithmetic") << "(2 * a * x + b)^2 = b^2 - 4 * a * c" << "(2 * a * x + b)^2 = b^2 - 4 * a * c\n\n"; QTest::newRow("escaped asterisk after newline") << "The first sentence of this paragraph holds 80 characters, then there's a star. * This is wrapped, but is not a bullet point." << "The first sentence of this paragraph holds 80 characters, then there's a star.\n\\* This is wrapped, but is *not* a bullet point.\n\n"; QTest::newRow("escaped plus after newline") << "The first sentence of this paragraph holds 80 characters, then there's a plus. + This is wrapped, but is not a bullet point." << "The first sentence of this paragraph holds 80 characters, then there's a plus.\n\\+ This is wrapped, but is *not* a bullet point.\n\n"; QTest::newRow("escaped hyphen after newline") << "The first sentence of this paragraph holds 80 characters, then there's a minus. - This is wrapped, but is not a bullet point." << "The first sentence of this paragraph holds 80 characters, then there's a minus.\n\\- This is wrapped, but is *not* a bullet point.\n\n"; QTest::newRow("list items with indented continuations") << "
  • bullet

    continuation paragraph

  • another bullet
    continuation line
" << "- bullet\n\n continuation paragraph\n\n- another bullet\n continuation line\n"; QTest::newRow("nested list items with continuations") << "
  • bullet

    continuation paragraph

  • another bullet
    continuation line
    • bullet

      continuation paragraph

    • another bullet
      continuation line
" << "- bullet\n\n continuation paragraph\n\n- another bullet\n continuation line\n\n - bullet\n\n continuation paragraph\n\n - another bullet\n continuation line\n"; QTest::newRow("nested ordered list items with continuations") << "
  1. item

    continuation paragraph

  2. another item
    continuation line
    1. item

      continuation paragraph

    2. another item
      continuation line
  3. another
  4. another
" << "1. item\n\n continuation paragraph\n\n2. another item\n continuation line\n\n 1. item\n\n continuation paragraph\n\n 2. another item\n continuation line\n\n3. another\n4. another\n"; QTest::newRow("thematic break") << "something
something else" << "something\n\n- - -\nsomething else\n\n"; QTest::newRow("block quote") << "

In 1958, Mahatma Gandhi was quoted as follows:

The Earth provides enough to satisfy every man's need but not for every man's greed.
" << "In 1958, Mahatma Gandhi was quoted as follows:\n\n> The Earth provides enough to satisfy every man's need but not for every man's\n> greed.\n\n"; QTest::newRow("image") << "\"foo\"" << "![foo](/url \"title\")\n\n"; QTest::newRow("code") << "
\n#include \"foo.h\"\n\nblock {\n    statement();\n}\n\n
" << "```pseudocode\n#include \"foo.h\"\n\nblock {\n statement();\n}\n\n```\n\n"; QTest::newRow("escaped number and paren after single newline") << "

(The first sentence of this paragraph is a line, next paragraph has a number 13) but that's not part of an ordered list

" << "(The first sentence of this paragraph is a line, next paragraph has a number\n13\\) but that's not part of an ordered list\n\n"; QTest::newRow("escaped number and paren after double newline") << "

(The first sentence of this paragraph is a line, the next paragraph has a number

13) but that's not part of an ordered list" << "(The first sentence of this paragraph is a line, the next paragraph has a number\n\n13\\) but that's not part of an ordered list\n\n"; QTest::newRow("preformats with embedded backticks") << "
none `one` ``two``
plain
```three``` ````four````
plain" << "```\nnone `one` ``two``\n\n```\nplain\n\n```\n```three``` ````four````\n\n```\nplain\n\n"; QTest::newRow("list items with and without checkboxes") << "
  • bullet
  • unchecked item
  • checked item
" << "- bullet\n- [ ] unchecked item\n- [x] checked item\n"; QTest::newRow("table with backslash in cell") << // QTBUG-96051 "
1011011 [1011100 backslash \\
" << "|1011011 [|1011100 backslash \\\\|"; // https://spec.commonmark.org/0.31.2/#example-12 // escaping punctuation is ok, but QTextMarkdownWriter currently doesn't do that (which is also ok) QTest::newRow("punctuation") << R"(

!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~

)" << R"(!"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~)"; // https://spec.commonmark.org/0.31.2/#example-14 QTest::newRow("backslash asterisk no emphasis") << // QTBUG-122083 R"(\*no emphasis*)" << R"(\\\*no emphasis*)"; // https://spec.commonmark.org/0.31.2/#example-15 QTest::newRow("backslash before emphasis") << R"(\emphasis)" << R"(\\*emphasis*)"; // https://spec.commonmark.org/0.31.2/#example-20 QTest::newRow("backslash-asterisk in autolink") << R"(

https://example.com?find=\*

)" << R"()"; // https://spec.commonmark.org/0.31.2/#example-24 QTest::newRow("plus in fenced code lang") << "
foo
" << "```foo+bar\nfoo\n```"; } void tst_QTextMarkdownWriter::fromHtml() { QFETCH(QString, input); QFETCH(QString, expectedOutput); document->setHtml(input); QString output = documentToUnixMarkdown(); #ifdef DEBUG_WRITE_OUTPUT { QFile out("/tmp/" + QLatin1String(QTest::currentDataTag()) + ".md"); out.open(QFile::WriteOnly); out.write(output.toUtf8()); out.close(); } #endif output = output.trimmed(); expectedOutput = expectedOutput.trimmed(); if (output != expectedOutput && (isMainFontFixed() || isFixedFontProportional())) QEXPECT_FAIL("", "fixed main font or proportional fixed font (QTBUG-103484)", Continue); QCOMPARE(output, expectedOutput); } void tst_QTextMarkdownWriter::fromPlainTextAndBack_data() { QTest::addColumn("input"); QTest::addColumn("expectedMarkdown"); // tests to verify that fixing QTBUG-122083 is safe QTest::newRow("single backslashes") << R"(\ again: \ not esc: \* \-\-\ \*abc*)" << R"(\\ again: \\ not esc: \\* \\-\\-\\ \\\*abc*)"; // https://spec.commonmark.org/0.31.2/#example-12 QTest::newRow("punctuation") << R"(!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~)" << R"(!"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~)"; // https://spec.commonmark.org/0.31.2/#example-13 QTest::newRow("literal backslashes") << QString(uR"(\→\A\a\ \3\φ\«)") << "\\\\\u2192\\\\A\\\\a\\\\ \\\\3\\\\\u03C6\\\\\u00AB"; // https://spec.commonmark.org/0.31.2/#example-14 QTest::newRow("escape to avoid em") << R"(*not emphasized*)" << R"(\*not emphasized*)"; QTest::newRow("escape to avoid html") << R"(
not a tag)" << R"(\
not a tag)"; QTest::newRow("escape to avoid link") << R"([not a link](/foo))" << R"(\[not a link](/foo))"; QTest::newRow("escape to avoid mono") << R"(`not code`)" << R"(\`not code`)"; QTest::newRow("escape to avoid num list") << R"(1. not a list)" << R"(1\. not a list)"; QTest::newRow("escape to avoid list") << R"(* not a list)" << R"(\* not a list)"; QTest::newRow("escape to avoid heading") << R"(# not a heading)" << R"(\# not a heading)"; QTest::newRow("escape to avoid reflink") << R"([foo]: /url "not a reference")" << R"(\[foo]: /url "not a reference")"; QTest::newRow("escape to avoid entity") << R"(ö not a character entity)" << R"(\ö not a character entity)"; // end of tests to verify that fixing QTBUG-122083 is safe // (it's ok to add unrelated plain-to-markdown-to-plaintext cases later) } void tst_QTextMarkdownWriter::fromPlainTextAndBack() { QFETCH(QString, input); QFETCH(QString, expectedMarkdown); document->setPlainText(input); QString output = documentToUnixMarkdown(); #ifdef DEBUG_WRITE_OUTPUT { QFile out("/tmp/" + QLatin1String(QTest::currentDataTag()) + ".md"); out.open(QFile::WriteOnly); out.write(output.toUtf8()); out.close(); } #endif output = output.trimmed(); expectedMarkdown = expectedMarkdown.trimmed(); if (output != expectedMarkdown && (isMainFontFixed() || isFixedFontProportional())) QSKIP("", "fixed main font or proportional fixed font (QTBUG-103484)"); QCOMPARE(output, expectedMarkdown); QCOMPARE(document->toPlainText(), input); document->setMarkdown(output); QCOMPARE(document->toPlainText(), input); if (document->blockCount() == 1) QCOMPARE(document->firstBlock().text(), input); } void tst_QTextMarkdownWriter::escapeSpecialCharacters_data() { QTest::addColumn("input"); QTest::addColumn("expectedOutput"); QTest::newRow("backslash") << "foo \\ bar \\\\ baz \\" << "foo \\\\ bar \\\\\\\\ baz \\\\"; QTest::newRow("not emphasized") << "*normal* **normal too**" << "\\*normal* \\**normal too**"; QTest::newRow("not code") << "`normal` `normal too`" << "\\`normal` \\`normal too`"; QTest::newRow("code fence") << "```not a fence; ``` no risk here; ```not a fence" // TODO slightly inconsistent << "\\```not a fence; ``` no risk here; \\```not a fence"; QTest::newRow("not html") << "

not a tag:
nope

" << "\\

not a tag: \\
nope\\

"; QTest::newRow("not a link") << "text [not a link](/foo)" << "text \\[not a link](/foo)"; QTest::newRow("not a circle") << "* polaris" << "\\* polaris"; QTest::newRow("not a square") << "+ groovy" << "\\+ groovy"; QTest::newRow("not a bullet") << "- stayin alive" << "\\- stayin alive"; QTest::newRow("arithmetic") << "1 + 2 - 3 * 4" << "1 + 2 - 3 * 4"; QTest::newRow("not a list") << "1. not a list" << "1\\. not a list"; QTest::newRow("not a list either") << "Jupiter and 10." << "Jupiter and 10."; QTest::newRow("not a heading") << "# not a heading" << "\\# not a heading"; QTest::newRow("a non-entity") << "ö not a character entity" << "\\ö not a character entity"; } /*! \internal If the user types into a Qt-based editor plain text that the markdown parser would misinterpret, escape it when we save to markdown to clarify that it's plain text. https://spec.commonmark.org/0.31.2/#backslash-escapes */ void tst_QTextMarkdownWriter::escapeSpecialCharacters() // QTBUG-96051, QTBUG-122083 { QFETCH(QString, input); QFETCH(QString, expectedOutput); document->setPlainText(input); QString output = documentToUnixMarkdown(); #ifdef DEBUG_WRITE_OUTPUT { QFile out("/tmp/" + QLatin1String(QTest::currentDataTag()) + ".md"); out.open(QFile::WriteOnly); out.write(output.toUtf8()); out.close(); } #endif output = output.trimmed(); if (output != expectedOutput && (isMainFontFixed() || isFixedFontProportional())) QEXPECT_FAIL("", "fixed main font or proportional fixed font (QTBUG-103484)", Continue); QCOMPARE(output, expectedOutput); } QTEST_MAIN(tst_QTextMarkdownWriter) #include "tst_qtextmarkdownwriter.moc"