// Copyright (C) 2016 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 #include #include #include #include #include #include #include #ifdef Q_OS_UNIX #include #endif Q_LOGGING_CATEGORY(lcTests, "qt.quick.tests") using namespace Qt::StringLiterals; class tst_qquicktextdocument : public QQmlDataTest { Q_OBJECT public: tst_qquicktextdocument(); private: std::pair fragmentsAndItalics(const QTextDocument *doc); bool isMainFontFixed(); private slots: void textDocumentWriter(); void customDocument(); void replaceDocument(); void sourceAndSave_data(); void sourceAndSave(); void loadErrorNoSuchFile(); void loadErrorPermissionDenied(); void overrideTextFormat_data(); void overrideTextFormat(); void changeCharFormatInRange_data(); void changeCharFormatInRange(); void independentDocumentsSameSource_data(); void independentDocumentsSameSource(); }; QString text = QStringLiteral("foo bar"); // similar to TestDocument in tst_qtextdocument.cpp class FakeImageDocument : public QTextDocument { public: inline FakeImageDocument(const QUrl &testUrl, const QString &testString) : url(testUrl), string(testString), resourceLoaded(false) {} bool hasResourceCached(); protected: virtual QVariant loadResource(int type, const QUrl &name) override; private: QUrl url; QString string; bool resourceLoaded; }; bool FakeImageDocument::hasResourceCached() { return resourceLoaded; } QVariant FakeImageDocument::loadResource(int type, const QUrl &name) { qCDebug(lcTests) << type << name << ": expecting" << int(QTextDocument::ImageResource) << url; if (type == QTextDocument::ImageResource && name == url) { resourceLoaded = true; return string; } return QTextDocument::loadResource(type, name); } tst_qquicktextdocument::tst_qquicktextdocument() : QQmlDataTest(QT_QMLTEST_DATADIR) { } /*! \internal Returns {fragmentCount, italicFragmentIndex}. If no italic fragment is found, italicFragmentIndex is -1. */ std::pair tst_qquicktextdocument::fragmentsAndItalics(const QTextDocument *doc) { int fragmentCount = 0; int italicFragment = -1; for (QTextBlock::iterator it = doc->firstBlock().begin(); !(it.atEnd()); ++it) { QTextFragment currentFragment = it.fragment(); if (currentFragment.charFormat().fontItalic()) italicFragment = fragmentCount; ++fragmentCount; qCDebug(lcTests) << (currentFragment.charFormat().fontItalic() ? "italic" : "roman") << currentFragment.text(); } return {fragmentCount, italicFragment}; } bool tst_qquicktextdocument::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; } void tst_qquicktextdocument::textDocumentWriter() { QQmlEngine e; QQmlComponent c(&e, testFileUrl("text.qml")); QObject* o = c.create(); QVERIFY(o); QQuickTextEdit *edit = qobject_cast(o); QVERIFY(edit); QQuickTextDocument* quickDocument = qobject_cast(edit->property("textDocument").value()); QVERIFY(quickDocument->textDocument() != nullptr); QBuffer output; output.open(QBuffer::ReadWrite); QVERIFY(output.buffer().isEmpty()); edit->setProperty("text", QVariant(text)); QTextDocumentWriter writer(&output, "plaintext"); QVERIFY(writer.write(quickDocument->textDocument())); QCOMPARE(output.buffer(), text.toLatin1()); delete o; } /*! \internal Verify that it's OK to replace the default QTextDocument that TextEdit creates with a user-created QTextDocument. Also verify that the user can still override QTextDocument::loadResource(). QTextDocument::loadResource() can call its QObject parent's loadResource(); the default QTextDocument's parent is the QQuickTextEdit, which provides an implementation of QQuickTextEdit::loadResource(), which uses QQuickPixmap to load and cache images. This will be bypassed if the user overrides loadResource() to do something different. */ void tst_qquicktextdocument::customDocument() { QQmlEngine e; QQmlComponent c(&e, testFileUrl("text.qml")); QScopedPointer textEdit(qobject_cast(c.create())); QCOMPARE(textEdit.isNull(), false); auto *textEditPriv = QQuickTextEditPrivate::get(textEdit.get()); QVERIFY(textEditPriv->ownsDocument); QQuickTextDocument *quickDocument = textEdit->property("textDocument").value(); QVERIFY(quickDocument); QPointer defaultDocument(quickDocument->textDocument()); const QString imageUrl = "https://www.qt.io/hubfs/Qt-logo-neon-small.png"; const QString fakeImageData = "foo!"; FakeImageDocument fdoc(QUrl(imageUrl), fakeImageData); quickDocument->setTextDocument(&fdoc); QVERIFY(defaultDocument.isNull()); // deleted because of being replaced (don't leak) QCOMPARE(textEditPriv->ownsDocument, false); // QQuickTextEdit::setText() -> QQuickTextControl::setHtml() -> // QQuickTextControlPrivate::setContent() -> fdoc->setHtml() // and eventually fdoc->loadResource() which substitutes a string for the requested image textEdit->setTextFormat(QQuickTextEdit::RichText); textEdit->setText("an image: "); QCOMPARE(fdoc.hasResourceCached(), true); auto firstBlock = fdoc.firstBlock(); // check that image loading has been bypassed by FakeImageDocument bool foundImage = false; int fragmentCount = 0; for (QTextBlock::iterator it = firstBlock.begin(); !(it.atEnd()); ++it) { QTextFragment currentFragment = it.fragment(); QVERIFY(currentFragment.isValid()); ++fragmentCount; const QString imageName = currentFragment.charFormat().stringProperty(QTextFormat::ImageName); if (!imageName.isEmpty()) { QCOMPARE(imageName, imageUrl); foundImage = true; QCOMPARE(fdoc.resource(QTextDocument::ImageResource, imageUrl).toString(), fakeImageData); } } QVERIFY(foundImage); QCOMPARE(fragmentCount, 2); } /*! \internal Verify that it's OK to replace the default QTextDocument that TextEdit creates with a user-created QTextDocument that has different text in it, and that interactive editing continues to function afterwards, independently of the previous document. */ void tst_qquicktextdocument::replaceDocument() // QTBUG-126267 { QQuickView window; QVERIFY(QQuickTest::showView(window, testFileUrl("initialText.qml"))); QQuickTextEdit *textEdit = qobject_cast(window.rootObject()); QVERIFY(textEdit); auto *textEditPriv = QQuickTextEditPrivate::get(textEdit); QVERIFY(textEditPriv->ownsDocument); QQuickTextDocument *quickDocument = textEdit->property("textDocument").value(); QVERIFY(quickDocument); QPointer defaultDocument(quickDocument->textDocument()); QTextDocument replacementDoc; const QString replacementText("Hello World"); { QTextCursor cursor(&replacementDoc); cursor.insertText(replacementText); } QCOMPARE(textEdit->text(), "Hello Qt"); QSignalSpy renderSpy(&window, &QQuickWindow::afterRendering); quickDocument->setTextDocument(&replacementDoc); QVERIFY(defaultDocument.isNull()); // deleted because of being replaced (don't leak) QCOMPARE(textEditPriv->ownsDocument, false); QCOMPARE(textEdit->text(), replacementText); QCOMPARE(replacementDoc.toPlainText(), replacementText); QTRY_COMPARE_GT(renderSpy.size(), 0); QCOMPARE(window.activeFocusItem(), textEdit); QTest::keyEvent(QTest::KeyAction::Click, &window, Qt::Key_End); QTest::keyEvent(QTest::KeyAction::Click, &window, '!'); QCOMPARE(textEdit->text(), replacementText + '!'); } void tst_qquicktextdocument::sourceAndSave_data() { QTest::addColumn("textFormat"); QTest::addColumn("source"); QTest::addColumn>("expectedEncoding"); QTest::addColumn("expectedTextFormat"); QTest::addColumn("minCharCount"); QTest::addColumn("expectedPlainText"); const std::optional nullEnc; QTest::newRow("plain") << QQuickTextEdit::PlainText << "hello.txt" << nullEnc << Qt::PlainText << 15 << u"Γειά σου Κόσμε!"_s; QTest::newRow("markdown") << QQuickTextEdit::MarkdownText << "hello.md" << nullEnc << Qt::MarkdownText << 15 << u"Γειά σου Κόσμε!"_s; QTest::newRow("html") << QQuickTextEdit::RichText << "hello.html" << std::optional(QStringConverter::Utf8) << Qt::RichText << 15 << u"Γειά σου Κόσμε!"_s; QTest::newRow("html-utf16be") << QQuickTextEdit::AutoText << "hello-utf16be.html" << std::optional(QStringConverter::Utf16BE) << Qt::RichText << 15 << u"Γειά σου Κόσμε!"_s; } void tst_qquicktextdocument::sourceAndSave() { QFETCH(QQuickTextEdit::TextFormat, textFormat); QFETCH(QString, source); QFETCH(std::optional, expectedEncoding); QFETCH(Qt::TextFormat, expectedTextFormat); QFETCH(int, minCharCount); QFETCH(QString, expectedPlainText); QQmlEngine e; QQmlComponent c(&e, testFileUrl("text.qml")); QScopedPointer textEdit(qobject_cast(c.create())); QCOMPARE(textEdit.isNull(), false); QQuickTextDocument *qqdoc = textEdit->property("textDocument").value(); QVERIFY(qqdoc); const QQmlContext *ctxt = e.rootContext(); // text.qml has text: "" but that's not a real change; QQuickTextEdit::setText() returns early // QQuickTextEditPrivate::init() also modifies defaults and then resets the modified state QCOMPARE(qqdoc->isModified(), false); // no stray signals should be visible to QML during init QCOMPARE(textEdit->property("modifiedChangeCount").toInt(), 0); QCOMPARE(textEdit->property("sourceChangeCount").toInt(), 0); QTextDocument *doc = qqdoc->textDocument(); QVERIFY(doc); QSignalSpy sourceChangedSpy(qqdoc, &QQuickTextDocument::sourceChanged); QSignalSpy modifiedChangedSpy(qqdoc, &QQuickTextDocument::modifiedChanged); QSignalSpy statusChangedSpy(qqdoc, &QQuickTextDocument::statusChanged); QTemporaryDir tmpDir; QVERIFY(tmpDir.isValid()); QFile sf(QQmlFile::urlToLocalFileOrQrc(ctxt->resolvedUrl(testFileUrl(source)))); qCDebug(lcTests) << source << "orig ->" << sf.fileName(); QVERIFY(sf.exists()); QString tmpPath = tmpDir.filePath(source); QVERIFY(sf.copy(tmpPath)); qCDebug(lcTests) << source << "copy ->" << tmpDir.path() << ":" << tmpPath; QCOMPARE(statusChangedSpy.size(), 0); QCOMPARE(qqdoc->status(), QQuickTextDocument::Status::Null); textEdit->setTextFormat(textFormat); qqdoc->setProperty("source", QUrl::fromLocalFile(tmpPath)); QCOMPARE(sourceChangedSpy.size(), 1); QCOMPARE(textEdit->property("sourceChangeCount").toInt(), 1); QCOMPARE(statusChangedSpy.size(), 2); // Loading, then Loaded QCOMPARE(qqdoc->status(), QQuickTextDocument::Status::Loaded); QVERIFY(qqdoc->errorString().isEmpty()); const auto *qqdp = QQuickTextDocumentPrivate::get(qqdoc); QCOMPARE(qqdp->detectedFormat, expectedTextFormat); QCOMPARE_GE(doc->characterCount(), minCharCount); QCOMPARE(doc->toPlainText().trimmed(), expectedPlainText); QCOMPARE(qqdp->encoding, expectedEncoding); textEdit->setText("hello"); QCOMPARE(qqdoc->isModified(), false); QCOMPARE_GE(textEdit->property("modifiedChangeCount").toInt(), 1); QCOMPARE(textEdit->property("modifiedChangeCount").toInt(), modifiedChangedSpy.size()); modifiedChangedSpy.clear(); textEdit->insert(5, "!"); QCOMPARE_GE(modifiedChangedSpy.size(), 1); QCOMPARE(qqdoc->isModified(), true); qqdoc->save(); QCOMPARE(statusChangedSpy.size(), 4); // Saving, then Saved QCOMPARE(qqdoc->status(), QQuickTextDocument::Status::Saved); QVERIFY(qqdoc->errorString().isEmpty()); QFile tf(tmpPath); QVERIFY(tf.open(QIODeviceBase::ReadOnly)); auto readBack = tf.readAll(); if (expectedTextFormat == Qt::RichText) { QStringDecoder dec(*expectedEncoding); const QString decStr = dec(readBack); QVERIFY(decStr.contains("hello!

")); } else { QVERIFY(readBack.contains("hello!")); } QCOMPARE(textEdit->property("sourceChangeCount").toInt(), sourceChangedSpy.size()); } void tst_qquicktextdocument::loadErrorNoSuchFile() { QQmlEngine e; QQmlComponent c(&e, testFileUrl("text.qml")); QScopedPointer textEdit(qobject_cast(c.create())); QCOMPARE(textEdit.isNull(), false); QQuickTextDocument *qqdoc = textEdit->property("textDocument").value(); QVERIFY(qqdoc); QCOMPARE(textEdit->property("sourceChangeCount").toInt(), 0); QTextDocument *doc = qqdoc->textDocument(); QVERIFY(doc); QSignalSpy sourceChangedSpy(qqdoc, &QQuickTextDocument::sourceChanged); QSignalSpy statusChangedSpy(qqdoc, &QQuickTextDocument::statusChanged); QCOMPARE(statusChangedSpy.size(), 0); QCOMPARE(qqdoc->status(), QQuickTextDocument::Status::Null); const QRegularExpression err(".*does not exist"); QTest::ignoreMessage(QtWarningMsg, err); qqdoc->setProperty("source", testFileUrl("nosuchfile.md")); QCOMPARE(sourceChangedSpy.size(), 1); QCOMPARE(textEdit->property("sourceChangeCount").toInt(), 1); qCDebug(lcTests) << "status history" << textEdit->property("statusHistory").toList(); QCOMPARE(statusChangedSpy.size(), 1); QCOMPARE(qqdoc->status(), QQuickTextDocument::Status::ReadError); QVERIFY(qqdoc->errorString().contains(err)); } void tst_qquicktextdocument::loadErrorPermissionDenied() { #ifdef Q_OS_UNIX if (geteuid() == 0) QSKIP("Permission will not be denied with root privileges."); #endif QQmlEngine e; QQmlComponent c(&e, testFileUrl("text.qml")); QScopedPointer textEdit(qobject_cast(c.create())); QCOMPARE(textEdit.isNull(), false); QQuickTextDocument *qqdoc = textEdit->property("textDocument").value(); QVERIFY(qqdoc); const QQmlContext *ctxt = e.rootContext(); QCOMPARE(textEdit->property("sourceChangeCount").toInt(), 0); QTextDocument *doc = qqdoc->textDocument(); QVERIFY(doc); QSignalSpy sourceChangedSpy(qqdoc, &QQuickTextDocument::sourceChanged); QSignalSpy statusChangedSpy(qqdoc, &QQuickTextDocument::statusChanged); QTemporaryDir tmpDir; QVERIFY(tmpDir.isValid()); const QString source("hello.md"); QFile sf(QQmlFile::urlToLocalFileOrQrc(ctxt->resolvedUrl(testFileUrl(source)))); qCDebug(lcTests) << source << "orig ->" << sf.fileName(); QVERIFY(sf.exists()); QString tmpPath = tmpDir.filePath(source); QVERIFY(sf.copy(tmpPath)); qCDebug(lcTests) << source << "copy ->" << tmpDir.path() << ":" << tmpPath; if (!QFile::setPermissions(tmpPath, QFileDevice::Permissions{})) // no permissions at all QSKIP("Failed to change permissions of temporary file: cannot continue."); QCOMPARE(statusChangedSpy.size(), 0); QCOMPARE(qqdoc->status(), QQuickTextDocument::Status::Null); const QRegularExpression err(".*Failed to read: Permission denied"); QTest::ignoreMessage(QtWarningMsg, err); qqdoc->setProperty("source", QUrl::fromLocalFile(tmpPath)); QCOMPARE(sourceChangedSpy.size(), 1); QCOMPARE(textEdit->property("sourceChangeCount").toInt(), 1); qCDebug(lcTests) << "status history" << textEdit->property("statusHistory").toList(); QCOMPARE(statusChangedSpy.size(), 1); QCOMPARE(qqdoc->status(), QQuickTextDocument::Status::ReadError); QVERIFY(qqdoc->errorString().contains(err)); } void tst_qquicktextdocument::overrideTextFormat_data() { QTest::addColumn("qmlfile"); QTest::addColumn("initialFormat"); QTest::addColumn("source"); QTest::addColumn("expectedInitialFragmentCount"); QTest::addColumn("expectedInitialItalicFragment"); // first part of TextEdit.text after loading QTest::addColumn("expectedTextPrefix"); QTest::addColumn("replacementFormat"); QTest::addColumn("expectedFragmentCount"); QTest::addColumn("expectedItalicFragment"); // first part of TextEdit.text after switching to replacementFormat QTest::addColumn("expectedReplacementPrefix"); QTest::addColumn("expectedTextChangedSignalsAfterReplacement"); QTest::addColumn("finalFormat"); QTest::addColumn("expectedFinalFragmentCount"); QTest::addColumn("expectedFinalItalicFragment"); // first part of TextEdit.text after switching to finalFormat QTest::addColumn("expectedFinalPrefix"); QTest::newRow("load md, switch to plain, back to md") << testFileUrl("text.qml") << QQuickTextEdit::MarkdownText << testFileUrl("hello.md") << 3 << 1 << u"Γειά σου *Κόσμε*!"_s << QQuickTextEdit::PlainText << 1 << -1 << u"Γειά σου *Κόσμε*!"_s << 2 << QQuickTextEdit::MarkdownText << 3 << 1 << u"Γειά σου *Κόσμε*!"_s; QTest::newRow("load md, switch to plain, then auto") << testFileUrl("text.qml") << QQuickTextEdit::MarkdownText << testFileUrl("hello.md") << 3 << 1 << u"Γειά σου *Κόσμε*!"_s << QQuickTextEdit::PlainText << 1 << -1 << u"Γειά σου *Κόσμε*!"_s << 2 << QQuickTextEdit::AutoText << 3 << 1 << u"Γειά σου *Κόσμε*!"_s; QTest::newRow("load md, switch to html, then plain") << testFileUrl("text.qml") << QQuickTextEdit::MarkdownText << testFileUrl("hello.md") << 3 << 1 << u"Γειά σου *Κόσμε*!"_s << QQuickTextEdit::RichText << 3 << 1 << u"Κόσμε!"_s << QQuickTextEdit::RichText << 3 << 1 << u"(window.rootObject()); QVERIFY(textEdit); QQuickTextDocument *qqdoc = textEdit->property("textDocument").value(); QVERIFY(qqdoc); QTextDocument *doc = qqdoc->textDocument(); QVERIFY(doc); textEdit->setTextFormat(initialFormat); QCOMPARE(qqdoc->isModified(), false); QCOMPARE(textEdit->property("sourceChangeCount").toInt(), 0); QSignalSpy sourceChangedSpy(qqdoc, &QQuickTextDocument::sourceChanged); QSignalSpy textChangedSpy(textEdit, &QQuickTextEdit::textChanged); qqdoc->setProperty("source", source); QCOMPARE(sourceChangedSpy.size(), 1); QCOMPARE(textEdit->property("sourceChangeCount").toInt(), 1); QCOMPARE_GE(textChangedSpy.size(), 1); auto fragCountAndItalic = fragmentsAndItalics(doc); QCOMPARE(fragCountAndItalic.first, expectedInitialFragmentCount); QCOMPARE(fragCountAndItalic.second, expectedInitialItalicFragment); QString textPropValue = textEdit->text(); qCDebug(lcTests) << "expect text()" << textPropValue.first(qMin(20, textPropValue.size() - 1)) << "to start with" << expectedTextPrefix; QVERIFY(textPropValue.startsWith(expectedTextPrefix)); textEdit->setTextFormat(replacementFormat); QCOMPARE(qqdoc->isModified(), false); QCOMPARE(sourceChangedSpy.size(), 1); QCOMPARE_GE(textChangedSpy.size(), expectedTextChangedSignalsAfterReplacement); fragCountAndItalic = fragmentsAndItalics(doc); QCOMPARE(fragCountAndItalic.first, expectedFragmentCount); QCOMPARE(fragCountAndItalic.second, expectedItalicFragment); textPropValue = textEdit->text(); qCDebug(lcTests) << "expect text()" << textPropValue.first(qMin(20, textPropValue.size() - 1)) << "to start with" << expectedReplacementPrefix; QVERIFY(textPropValue.startsWith(expectedReplacementPrefix)); textEdit->setTextFormat(finalFormat); QCOMPARE(qqdoc->isModified(), false); QCOMPARE(sourceChangedSpy.size(), 1); QCOMPARE_GE(textChangedSpy.size(), expectedTextChangedSignalsAfterReplacement + 1); fragCountAndItalic = fragmentsAndItalics(doc); QCOMPARE(fragCountAndItalic.first, expectedFinalFragmentCount); QCOMPARE(fragCountAndItalic.second, expectedFinalItalicFragment); textPropValue = textEdit->text(); qCDebug(lcTests) << "expect text()" << textPropValue.first(qMin(20, textPropValue.size() - 1)) << "to start with" << expectedFinalPrefix; QVERIFY(textPropValue.startsWith(expectedFinalPrefix)); } void tst_qquicktextdocument::changeCharFormatInRange_data() { QTest::addColumn("editBlock"); QTest::newRow("begin/end") << true; QTest::newRow("no edit block") << false; // QTBUG-126886 : don't crash } void tst_qquicktextdocument::changeCharFormatInRange() { QFETCH(bool, editBlock); QQuickView window; QVERIFY(QQuickTest::showView(window, testFileUrl("initialText.qml"))); QQuickTextEdit *textEdit = qobject_cast(window.rootObject()); QVERIFY(textEdit); QVERIFY(textEdit->textDocument()); auto *doc = textEdit->textDocument()->textDocument(); QVERIFY(doc); QSignalSpy contentSpy(doc, &QTextDocument::contentsChanged); const auto data = QStringLiteral("Format String"); doc->setPlainText(data); auto block = doc->findBlockByNumber(0); auto formatText = [block, data] { QTextLayout::FormatRange formatText; formatText.start = 0; formatText.length = data.size(); formatText.format.setForeground(Qt::green); block.layout()->setFormats({formatText}); }; // change the char format of this block, and verify visual effect if (editBlock) { QTextCursor cursor(doc); cursor.beginEditBlock(); formatText(); cursor.endEditBlock(); } else { formatText(); } QVERIFY(QQuickTest::qWaitForPolish(textEdit)); QCOMPARE(contentSpy.size(), editBlock ? 2 : 1); } void tst_qquicktextdocument::independentDocumentsSameSource_data() { QTest::addColumn("qmlfile"); QTest::newRow("textFormat above source") << testFileUrl("sideBySideIndependent.qml"); QTest::newRow("source above textFormat") << testFileUrl("sideBySideIndependentReverse.qml"); } // ensure that two TextEdits' textFormat properties take effect, regardless of qml init order void tst_qquicktextdocument::independentDocumentsSameSource() // QTBUG-120772 { QFETCH(QUrl, qmlfile); QQuickView window; QVERIFY(QQuickTest::showView(window, qmlfile)); QQuickTextEdit *textEditPlain = window.rootObject()->findChild("plain"); QVERIFY(textEditPlain); QQuickTextEdit *textEditMarkdown = window.rootObject()->findChild("markdown"); QVERIFY(textEditMarkdown); auto fragCountAndItalic = fragmentsAndItalics(textEditPlain->textDocument()->textDocument()); QCOMPARE(fragCountAndItalic.first, 1); QCOMPARE(fragCountAndItalic.second, -1); fragCountAndItalic = fragmentsAndItalics(textEditMarkdown->textDocument()->textDocument()); QCOMPARE(fragCountAndItalic.first, 3); QCOMPARE(fragCountAndItalic.second, 1); } QTEST_MAIN(tst_qquicktextdocument) #include "tst_qquicktextdocument.moc"