From fcf2259e5135d52c14a632ea9a56f9dd01c58591 Mon Sep 17 00:00:00 2001 From: klg90 <284782940+klg90@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:32:59 -0400 Subject: [PATCH 1/4] qt: fit selective title gradients to their rendered text ranges Match Kdenlive's QTextLayout-based range geometry for selective title gradients. Keep legacy whole-object gradients and existing saved metadata compatible, including typewriter rendering. Add image regressions for positioned ranges, multiple styles, alignment, multiline and bidi text. Relevant native MLT suites passed. --- src/modules/qt/kdenlivetitle_wrapper.cpp | 9 +- src/modules/qt/richtextgradient.h | 208 +++++++++++- src/tests/CMakeLists.txt | 8 + .../test_richtextgradient.cpp | 306 ++++++++++++++++++ 4 files changed, 518 insertions(+), 13 deletions(-) create mode 100644 src/tests/test_richtextgradient/test_richtextgradient.cpp diff --git a/src/modules/qt/kdenlivetitle_wrapper.cpp b/src/modules/qt/kdenlivetitle_wrapper.cpp index d70184361..629f16518 100644 --- a/src/modules/qt/kdenlivetitle_wrapper.cpp +++ b/src/modules/qt/kdenlivetitle_wrapper.cpp @@ -532,7 +532,6 @@ void loadFromXml(producer_ktitle self, qWarning() << "Ignoring invalid title rich-text gradient metadata"; } txt->document()->setDocumentMargin(0); - TitlerGradientV1::applyBrushes(txt->document(), int(boxWidth), int(boxHeight)); QTextOption option = txt->document()->defaultTextOption(); if (tabWidth > 0) { @@ -592,6 +591,14 @@ void loadFromXml(producer_ktitle self, txt->setTextWidth(boxWidth); } + // Selective brushes use the final laid-out glyph positions. A + // legacy object gradient retains precedence over run metadata. + if (txtProperties.namedItem("gradient").isNull()) { + TitlerGradientV1::applyBrushes(txt->document(), + int(boxWidth), + int(boxHeight)); + } + if (!txtProperties.namedItem("shadow").isNull()) { const QStringList values = txtProperties.namedItem("shadow").nodeValue().split(";"); diff --git a/src/modules/qt/richtextgradient.h b/src/modules/qt/richtextgradient.h index 9e255918f..32cafedec 100644 --- a/src/modules/qt/richtextgradient.h +++ b/src/modules/qt/richtextgradient.h @@ -1,16 +1,21 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #pragma once +#include #include #include #include +#include #include +#include +#include #include #include #include #include #include #include +#include #include #include @@ -129,36 +134,215 @@ inline bool restore(const QDomElement &content, QTextDocument *text) return true; } -inline void applyBrushes(QTextDocument *text, int width, int height) +// A region is a maximal logical range with the same recipe. Formatting can split +// it into several QTextFragments; those fragments must share one gradient. +// Version 1 has no region identity, so adjacent applications of the same recipe +// are indistinguishable. Paragraph separators are not serialized as runs and do +// not interrupt an otherwise continuous region; unformatted text always does. +struct Region { + int start; + int length; + QString data; + QRectF bounds; +}; + +struct LayoutRange +{ + int start; + int length; +}; + +// QTextLayout positions include the input method's preedit insertion, while +// document/serialized positions do not. Exclude the preedit from both geometry +// and the additional gradient formats, preserving its own input-method formats. +inline QVector layoutRanges(const QTextLayout *layout, int start, int length) +{ + const int preedit = layout->preeditAreaPosition(); + const int extra = int(layout->preeditAreaText().size()); + if (extra == 0 || preedit < 0 || start + length <= preedit) { + return {{start, length}}; + } + if (start >= preedit) { + return {{start + extra, length}}; + } + return {{start, preedit - start}, {preedit + extra, start + length - preedit}}; +} + +inline QRectF rangeBounds(QTextDocument *text, int start, int length) +{ + const int end = start + length; + QRectF bounds; + QRectF whitespaceBounds; + const auto unite = [](QRectF &target, const QRectF &part) { + if (!part.isEmpty()) { + target = target.isEmpty() ? part : target.united(part); + } + }; + for (QTextBlock block = text->findBlock(start); block.isValid() && block.position() < end; + block = block.next()) { + const QTextLayout *layout = block.layout(); + if (!layout) { + continue; + } + // Unlike layout->position(), this also includes any containing frames. + const QPointF origin = text->documentLayout()->blockBoundingRect(block).topLeft(); + for (auto it = block.begin(); !it.atEnd(); ++it) { + const QTextFragment fragment = it.fragment(); + if (!fragment.isValid()) { + continue; + } + const int a = qMax(start, fragment.position()); + const int b = qMin(end, fragment.position() + fragment.length()); + if (a >= b) { + continue; + } + const QTextCharFormat format = fragment.charFormat(); + for (const LayoutRange &part : layoutRanges(layout, a - block.position(), b - a)) { + for (int i = 0; i < layout->lineCount(); ++i) { + const QTextLine line = layout->lineAt(i); + const int from = qMax(part.start, line.textStart()); + const int to = qMin(part.start + part.length, + line.textStart() + line.textLength()); + if (from >= to) { + continue; + } + // Query each visual line separately: QTextLayout::glyphRuns + // can merge runs on different lines, losing per-line clips. + for (const QGlyphRun &run : line.glyphRuns(from, to - from)) { + const QRawFont font = run.rawFont(); + if (!font.isValid()) { + continue; + } + const qreal fontHeight = font.ascent() + font.descent(); + qreal baseline = -fontHeight * format.baselineOffset() / 100.0; + if (format.verticalAlignment() == QTextCharFormat::AlignSubScript) { + baseline += fontHeight * format.subScriptBaseline() / 100.0; + } else if (format.verticalAlignment() == QTextCharFormat::AlignSuperScript) { + baseline -= fontHeight * format.superScriptBaseline() / 100.0; + } + const QPointF offset = origin + QPointF(0, baseline); + unite(whitespaceBounds, run.boundingRect().translated(offset)); + const auto glyphs = run.glyphIndexes(); + const auto positions = run.positions(); + for (int g = 0; g < qMin(glyphs.size(), positions.size()); ++g) { + QRectF ink = font.boundingRect(glyphs.at(g)).translated(positions.at(g)); + if (run.flags().testFlag(QGlyphRun::SplitLigature)) { + // The range owns only part of this shared glyph. + // Keep vertical ink (accents/overhangs), clipping + // only to Qt's selected horizontal interval. + ink.setLeft(qMax(ink.left(), run.boundingRect().left())); + ink.setRight(qMin(ink.right(), run.boundingRect().right())); + } + unite(bounds, ink.translated(offset)); + } + } + } + } + } + } + // Whitespace has advance but no ink. It must not enlarge a painted region, + // but supplies a harmless coordinate system for a whitespace-only region. + return bounds.isEmpty() ? whitespaceBounds : bounds; +} + +inline QVector regions(QTextDocument *text) +{ + QVector result; + for (QTextBlock block = text->begin(); block.isValid(); block = block.next()) { + for (auto it = block.begin(); !it.atEnd(); ++it) { + const QTextFragment fragment = it.fragment(); + if (!fragment.isValid()) { + continue; + } + const QString data = fragment.charFormat().property(Property).toString(); + if (data.isEmpty()) { + continue; + } + bool adjacent = false; + if (!result.isEmpty() && result.last().data == data) { + const int previousEnd = result.last().start + result.last().length; + adjacent = previousEnd <= fragment.position(); + for (int p = previousEnd; adjacent && p < fragment.position(); ++p) { + adjacent = text->characterAt(p) == QChar::ParagraphSeparator; + } + } + if (adjacent) { + result.last().length = fragment.position() + fragment.length() + - result.last().start; + } else { + result.append({fragment.position(), fragment.length(), data, {}}); + } + } + } + if (!result.isEmpty()) { + // Resolve wrapping, alignment and font metrics before measuring. Resolve + // every region before changing any presentation/foreground formats. + text->documentLayout()->documentSize(); + for (Region ®ion : result) { + region.bounds = rangeBounds(text, region.start, region.length); + } + } + return result; +} + +inline QLinearGradient gradientFromString(const QString &data, const QRectF &bounds) +{ + // The existing unit-size recipe preserves angle/stop semantics. Scale and + // translate in qreal coordinates rather than rounding selected glyph bounds. + QLinearGradient gradient = gradientFromString(data, 1, 1); + const auto point = [&bounds](const QPointF &p) { + return bounds.topLeft() + QPointF(p.x() * bounds.width(), p.y() * bounds.height()); + }; + gradient.setStart(point(gradient.start())); + gradient.setFinalStop(point(gradient.finalStop())); + return gradient; +} + +inline void applyBrushes(QTextDocument *text, int, int) +{ + // Resolve every region before foreground edits can invalidate the layout. + // Apply per fragment as before: paragraph separators and insertion formats + // must not be changed merely because a region spans several blocks. + const auto resolved = regions(text); struct Run { int start; int length; - QString data; + QBrush brush; }; QVector runs; + int regionIndex = 0; for (QTextBlock block = text->begin(); block.isValid(); block = block.next()) { for (auto it = block.begin(); !it.atEnd(); ++it) { const QTextFragment fragment = it.fragment(); - if (!fragment.isValid()) + if (!fragment.isValid() + || fragment.charFormat().property(Property).toString().isEmpty()) { continue; - const QString data = fragment.charFormat().property(Property).toString(); - if (!data.isEmpty()) - runs.append({fragment.position(), fragment.length(), data}); + } + while (regionIndex < resolved.size() + && resolved.at(regionIndex).start + resolved.at(regionIndex).length + <= fragment.position()) { + ++regionIndex; + } + if (regionIndex >= resolved.size()) { + continue; + } + const Region ®ion = resolved.at(regionIndex); + const QBrush brush(gradientFromString(region.data, region.bounds)); + if (fragment.charFormat().foreground() != brush) { + runs.append({fragment.position(), fragment.length(), brush}); + } } } QTextCursor cursor(text); cursor.beginEditBlock(); for (const Run &run : runs) { - const QBrush brush(gradientFromString(run.data, width, height)); cursor.setPosition(run.start); cursor.setPosition(run.start + run.length, QTextCursor::KeepAnchor); - if (cursor.charFormat().foreground() != brush) { - QTextCharFormat delta; - delta.setForeground(brush); - cursor.mergeCharFormat(delta); - } + QTextCharFormat delta; + delta.setForeground(run.brush); + cursor.mergeCharFormat(delta); } cursor.endEditBlock(); } diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 10555caab..b0f711535 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -65,6 +65,14 @@ if(MOD_QT6) ) endif() + +if(MOD_QT6 AND BUILD_TESTS_WITH_QT6) + add_qt_test( + TEST_NAME richtextgradient + LINK_LIBRARIES Qt6::Gui Qt6::Widgets Qt6::Xml + ) +endif() + if(Kwalify_FOUND) file(GLOB YML_FILES "${CMAKE_SOURCE_DIR}/src/modules/*/*.yml") foreach(YML_FILE ${YML_FILES}) diff --git a/src/tests/test_richtextgradient/test_richtextgradient.cpp b/src/tests/test_richtextgradient/test_richtextgradient.cpp new file mode 100644 index 000000000..ed2078830 --- /dev/null +++ b/src/tests/test_richtextgradient/test_richtextgradient.cpp @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { +const QString recipe = QStringLiteral("#ffffff00;#ffff0000;0;100;0"); + +void configureDocument(QTextDocument &document, int start, Qt::Alignment alignment = Qt::AlignLeft) +{ + QFont font(QStringLiteral("DejaVu Sans")); + font.setPixelSize(64); + document.setDefaultFont(font); + document.setDocumentMargin(0); + document.setPlainText(QStringLiteral("MMMM XXXX")); + document.setTextWidth(600); + QTextCursor cursor(&document); + cursor.select(QTextCursor::Document); + QTextCharFormat white; + white.setForeground(QBrush(Qt::white)); + cursor.mergeCharFormat(white); + QTextBlockFormat block; + block.setAlignment(alignment); + cursor.mergeBlockFormat(block); + cursor.setPosition(start); + cursor.setPosition(start + 4, QTextCursor::KeepAnchor); + QTextCharFormat gradient; + gradient.setProperty(TitlerGradientV1::Property, recipe); + cursor.mergeCharFormat(gradient); +} + +QImage renderDocument(QTextDocument &document) +{ + QImage image(1280, 720, QImage::Format_RGBA8888); + image.fill(Qt::transparent); + QPainter painter(&image); + painter.setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing); + painter.translate(40, 40); + document.documentLayout()->draw(&painter, {}); + painter.end(); + return image; +} + +bool coversGradient(const QImage &image) +{ + int minimum = 255, maximum = 0, interiors = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const QColor color = image.pixelColor(x, y); + if (color.alpha() > 240 && color.red() > 220 && color.blue() < 20) { + minimum = qMin(minimum, color.green()); + maximum = qMax(maximum, color.green()); + ++interiors; + } + } + } + return interiors > 100 && minimum < 65 && maximum > 190; +} + +QImage coloredPixels(const QImage &image) +{ + QImage selected(image.size(), QImage::Format_RGBA8888); + selected.fill(Qt::transparent); + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const QColor color = image.pixelColor(x, y); + if (color.alpha() > 0 && color.red() > 200 && color.blue() < 32) + selected.setPixelColor(x, y, color); + } + } + return selected; +} + +QDomDocument titleXml(QTextDocument &document, Qt::Alignment alignment, bool animate = false) +{ + QDomDocument xml; + auto root = xml.createElement(QStringLiteral("kdenlivetitle")); + root.setAttribute(QStringLiteral("width"), 1280); + root.setAttribute(QStringLiteral("height"), 720); + root.setAttribute(QStringLiteral("out"), 299); + xml.appendChild(root); + auto item = xml.createElement(QStringLiteral("item")); + item.setAttribute(QStringLiteral("type"), QStringLiteral("QGraphicsTextItem")); + item.setAttribute(QStringLiteral("z-index"), 0); + root.appendChild(item); + auto position = xml.createElement(QStringLiteral("position")); + position.setAttribute(QStringLiteral("x"), 40); + position.setAttribute(QStringLiteral("y"), 40); + auto transform = xml.createElement(QStringLiteral("transform")); + transform.appendChild(xml.createTextNode(QStringLiteral("1,0,0,0,1,0,0,0,1"))); + position.appendChild(transform); + item.appendChild(position); + auto content = xml.createElement(QStringLiteral("content")); + content.setAttribute(QStringLiteral("font"), QStringLiteral("DejaVu Sans")); + content.setAttribute(QStringLiteral("font-pixel-size"), 64); + content.setAttribute(QStringLiteral("font-weight"), int(QFont::Normal)); + content.setAttribute(QStringLiteral("font-color"), QStringLiteral("255,255,255,255")); + content.setAttribute(QStringLiteral("font-outline"), 0); + content.setAttribute(QStringLiteral("font-outline-color"), QStringLiteral("0,0,0,255")); + content.setAttribute(QStringLiteral("box-width"), 600); + content.setAttribute(QStringLiteral("box-height"), document.size().height()); + content.setAttribute(QStringLiteral("preferred-width"), 600); + content.setAttribute(QStringLiteral("alignment"), int(alignment)); + content.setAttribute(QStringLiteral("typewriter"), + animate ? QStringLiteral("1;2;1;0;37") : QStringLiteral("0;2;1;0;37")); + content.appendChild(xml.createTextNode(document.toPlainText())); + auto rich = xml.createElement(QStringLiteral("richtext")); + rich.setAttribute(QStringLiteral("format"), QStringLiteral("qt-html-v1")); + rich.appendChild(xml.createTextNode(document.toHtml())); + content.appendChild(rich); + content.appendChild(TitlerGradientV1::save(xml, &document)); + item.appendChild(content); + auto background = xml.createElement(QStringLiteral("background")); + background.setAttribute(QStringLiteral("color"), QStringLiteral("0,0,0,0")); + root.appendChild(background); + return xml; +} + +void setTitle(Mlt::Producer &producer, const QDomDocument &xml) +{ + producer.set("xmldata", xml.toByteArray().constData()); + producer.set("length", 300); + producer.set_in_and_out(0, 299); + producer.set("force_reload", 1); +} + +QImage renderFrame(Mlt::Producer &producer, int position) +{ + producer.seek(position); + std::unique_ptr frame(producer.get_frame()); + if (!frame || !frame->is_valid()) + return {}; + mlt_image_format format = mlt_image_rgba; + int width = 1280, height = 720; + auto *data = frame->get_image(format, width, height); + if (!data || format != mlt_image_rgba || width != 1280 || height != 720) + return {}; + return QImage(data, width, height, width * 4, QImage::Format_RGBA8888).copy(); +} +} // namespace + +class TestRichTextGradient : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void initTestCase() + { + auto *repository = Mlt::Factory::init(); + QVERIFY(repository); + std::unique_ptr metadata( + repository->metadata(mlt_service_producer_type, "kdenlivetitle")); + QVERIFY2(metadata && metadata->get_double("version") >= 8, + "The tested Qt module must provide the rich-text kdenlivetitle producer (version " + ">= 8)"); + } + + void selectiveExport_data() + { + QTest::addColumn("start"); + QTest::addColumn("alignment"); + for (int start : {0, 5}) { + for (int alignment : {int(Qt::AlignLeft), int(Qt::AlignHCenter), int(Qt::AlignRight)}) { + const QByteArray name = QByteArray::number(start) + '-' + + QByteArray::number(alignment); + QTest::newRow(name.constData()) << start << alignment; + } + } + } + + void selectiveExport() + { + QFETCH(int, start); + QFETCH(int, alignment); + QTextDocument document; + configureDocument(document, start, Qt::Alignment(alignment)); + TitlerGradientV1::applyBrushes(&document, 600, int(document.size().height())); + const QImage expected = renderDocument(document); + QVERIFY(coversGradient(expected)); + Mlt::Profile profile("atsc_720p_25"); + Mlt::Producer producer(profile, "kdenlivetitle", ""); + QVERIFY(producer.is_valid()); + setTitle(producer, titleXml(document, Qt::Alignment(alignment))); + const QImage exported = renderFrame(producer, 240); + QVERIFY(coversGradient(exported)); + QCOMPARE(exported, expected); + } + + void suffixDeletionAndPersistence() + { + QTextDocument document; + configureDocument(document, 0); + TitlerGradientV1::applyBrushes(&document, 600, int(document.size().height())); + const QImage before = coloredPixels(renderDocument(document)); + QTextCursor cursor(&document); + cursor.setPosition(4); + cursor.setPosition(document.characterCount() - 1, QTextCursor::KeepAnchor); + cursor.removeSelectedText(); + document.setTextWidth(-1); + const QSizeF size = document.size(); + TitlerGradientV1::applyBrushes(&document, int(size.width()), int(size.height())); + const QImage after = coloredPixels(renderDocument(document)); + QVERIFY(coversGradient(after)); + QCOMPARE(after, before); + QDomDocument xml; + auto content = xml.createElement(QStringLiteral("content")); + xml.appendChild(content); + content.appendChild(TitlerGradientV1::save(xml, &document)); + QTextDocument restored; + restored.setHtml(document.toHtml()); + restored.setDocumentMargin(0); + restored.setTextWidth(-1); + QVERIFY(TitlerGradientV1::restore(content, &restored)); + const QSizeF restoredSize = restored.size(); + TitlerGradientV1::applyBrushes(&restored, + int(restoredSize.width()), + int(restoredSize.height())); + QCOMPARE(renderDocument(restored), renderDocument(document)); + } + + void typewriterKeepsFullRegionCoordinates() + { + QTextDocument document; + configureDocument(document, 0, Qt::AlignRight); + TitlerGradientV1::applyBrushes(&document, 600, int(document.size().height())); + Mlt::Profile profile("atsc_720p_25"); + Mlt::Producer reference(profile, "kdenlivetitle", ""); + Mlt::Producer native(profile, "kdenlivetitle", ""); + Mlt::Producer external(profile, "kdenlivetitle", ""); + QVERIFY(reference.is_valid()); + QVERIFY(native.is_valid()); + QVERIFY(external.is_valid()); + const auto staticXml = titleXml(document, Qt::AlignRight); + setTitle(reference, staticXml); + setTitle(native, titleXml(document, Qt::AlignRight, true)); + setTitle(external, staticXml); + Mlt::Filter effect(profile, "typewriter"); + QVERIFY(effect.is_valid()); + effect.set("macro_type", 1); + effect.set("step_length", 2); + effect.set("step_sigma", 0); + effect.set("random_seed", 37); + QCOMPARE(external.attach(effect), 0); + const QImage finalImage = renderFrame(reference, 240); + QVERIFY(coversGradient(finalImage)); + for (Mlt::Producer *animated : {&native, &external}) { + const QByteArray xmlBefore(animated->get("xmldata")); + const QImage early = renderFrame(*animated, 0); + QVERIFY(!early.isNull()); + QVERIFY(early != finalImage); + int interiors = 0; + for (int y = 0; y < early.height(); ++y) { + for (int x = 0; x < early.width(); ++x) { + const QColor color = early.pixelColor(x, y); + if (color.alpha() == 255) { + QCOMPARE(color, finalImage.pixelColor(x, y)); + ++interiors; + } + } + } + QVERIFY(interiors > 20); + QCOMPARE(renderFrame(*animated, 240), finalImage); + QCOMPARE(renderFrame(*animated, 0), early); + QCOMPARE(QByteArray(animated->get("xmldata")), xmlBefore); + } + } + + void legacyObjectGradientTakesPrecedence() + { + QTextDocument document; + configureDocument(document, 5, Qt::AlignRight); + const auto xmlBefore = titleXml(document, Qt::AlignRight); + QDomDocument xml(xmlBefore); + auto content = xml.documentElement() + .firstChildElement(QStringLiteral("item")) + .firstChildElement(QStringLiteral("content")); + content.setAttribute(QStringLiteral("gradient"), recipe); + QTextCursor cursor(&document); + cursor.select(QTextCursor::Document); + QTextCharFormat legacy; + legacy.setForeground(QBrush( + TitlerGradientV1::gradientFromString(recipe, 600, int(document.size().height())))); + cursor.mergeCharFormat(legacy); + const QImage expected = renderDocument(document); + Mlt::Profile profile("atsc_720p_25"); + Mlt::Producer producer(profile, "kdenlivetitle", ""); + QVERIFY(producer.is_valid()); + setTitle(producer, xml); + QCOMPARE(renderFrame(producer, 240), expected); + } +}; + +QTEST_MAIN(TestRichTextGradient) + +#include "test_richtextgradient.moc" From e781b747cbd714460453ee25b5d79e13baaff9e2 Mon Sep 17 00:00:00 2001 From: klg90 <284782940+klg90@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:33:00 -0400 Subject: [PATCH 2/4] qt: render selective rich-text title shadows Read validated per-character shadow overrides while preserving legacy object shadows. Match Kdenlive's glyph, decoration and outline masks, including bidi text, transparency, mixed fonts and typewriter reveals. Advertise the new capability through producer version 9. Add native image, metadata and typewriter regressions. Relevant native MLT suites passed; 192 legacy frame comparisons were byte-identical. --- src/modules/qt/kdenlivetitle_wrapper.cpp | 13 +- src/modules/qt/producer_kdenlivetitle.yml | 2 +- src/modules/qt/richtextanimation.h | 56 +- src/modules/qt/richtextshadow.h | 779 ++++++++++++++++++ src/tests/CMakeLists.txt | 4 + .../test_richtextshadow.cpp | 427 ++++++++++ 6 files changed, 1273 insertions(+), 8 deletions(-) create mode 100644 src/modules/qt/richtextshadow.h create mode 100644 src/tests/test_richtextshadow/test_richtextshadow.cpp diff --git a/src/modules/qt/kdenlivetitle_wrapper.cpp b/src/modules/qt/kdenlivetitle_wrapper.cpp index 629f16518..fc736c79a 100644 --- a/src/modules/qt/kdenlivetitle_wrapper.cpp +++ b/src/modules/qt/kdenlivetitle_wrapper.cpp @@ -599,11 +599,22 @@ void loadFromXml(producer_ktitle self, int(boxHeight)); } + if (!TitlerShadowV1::restore(contentElement, txt->document())) { + qWarning() << "Ignoring invalid title rich-text shadow metadata"; + } if (!txtProperties.namedItem("shadow").isNull()) { const QStringList values = txtProperties.namedItem("shadow").nodeValue().split(";"); - if (values.count() >= 5 && values.at(0).toInt() != 0) { + if (values.count() >= 5) { + txt->setShadowFallback({values.at(0).toInt() != 0, + QColor(values.at(1)), + values.at(2).toInt(), + values.at(3).toInt(), + values.at(4).toInt()}); + } + if (values.count() >= 5 && values.at(0).toInt() != 0 + && !TitlerShadowV1::hasOverrides(txt->document())) { auto *shadow = new QGraphicsDropShadowEffect(); shadow->setColor(QColor(values.at(1))); shadow->setBlurRadius(values.at(2).toDouble()); diff --git a/src/modules/qt/producer_kdenlivetitle.yml b/src/modules/qt/producer_kdenlivetitle.yml index 4a41c3fef..e3fac4b1c 100644 --- a/src/modules/qt/producer_kdenlivetitle.yml +++ b/src/modules/qt/producer_kdenlivetitle.yml @@ -2,7 +2,7 @@ schema_version: 7.2 type: producer identifier: kdenlivetitle title: Kdenlive Titler -version: 8 +version: 9 copyright: Marco Gittler, Jean-Baptiste Mardelle creator: Marco Gittler, Jean-Baptiste Mardelle license: LGPLv2.1 diff --git a/src/modules/qt/richtextanimation.h b/src/modules/qt/richtextanimation.h index da1273148..530a422ae 100644 --- a/src/modules/qt/richtextanimation.h +++ b/src/modules/qt/richtextanimation.h @@ -2,6 +2,7 @@ #pragma once #include "richtextoutline.h" +#include "richtextshadow.h" #include #include @@ -103,6 +104,7 @@ class Schedule class Item : public QGraphicsTextItem { public: + void setShadowFallback(const TitlerShadowV1::Style &style) { m_shadowStyle = style; } void setOutline(qreal width, const QColor &color) { width = std::isfinite(width) ? std::max(qreal(0), width) : qreal(0); @@ -123,6 +125,7 @@ class Item : public QGraphicsTextItem { const qreal margin = m_outlineMargin; return QGraphicsTextItem::boundingRect() + .united(m_shadowBounds) .united(m_inkBounds) .adjusted(-margin, -margin, margin, margin); } @@ -131,6 +134,11 @@ class Item : public QGraphicsTextItem prepareGeometryChange(); m_inkBounds = TitlerOutline::inkBounds(document()); m_outlineMargin = TitlerOutline::margin(document(), m_outlinePen); + m_shadowOverrides = TitlerShadowV1::hasOverrides(document()); + m_shadowBounds = m_shadowOverrides + ? TitlerShadowV1::bounds(document(), m_shadowStyle, m_outlinePen) + : QRectF(); + m_shadowVisible = -1; m_total = document()->characterCount() - 1; m_text = document()->toPlainText(); m_visible = m_total; @@ -175,6 +183,7 @@ class Item : public QGraphicsTextItem int lineId; QTextCharFormat format; QRawFont physicalFont; + QRectF lineBounds; }; QVector visibleCells() const @@ -228,16 +237,22 @@ class Item : public QGraphicsTextItem const qreal a = line.cursorToX(start); const qreal z = line.cursorToX(end); const QPointF origin = layout->position(); - const QRectF rect(origin.x() + std::min(a, z), - origin.y() + line.y(), - std::abs(z - a), - line.height()); + // Preserve the legacy title path; selective shadows and their + // visible text share the same shaped decoration intervals. + const QRectF rect + = m_shadowOverrides + ? TitlerShadowV1::decorationRect(line, start, end - start, runs, origin) + : QRectF(origin.x() + std::min(a, z), + origin.y() + line.y(), + std::abs(z - a), + line.height()); cells.append({rect, block.position() + end <= m_visible ? rect : QRectF(), origin.y() + line.y() + line.ascent(), block.position() + line.textStart(), format, - physicalFont}); + physicalFont, + line.naturalTextRect().translated(origin)}); } } std::sort(cells.begin(), cells.end(), [](const VisualCell &a, const VisualCell &b) { @@ -395,6 +410,17 @@ class Item : public QGraphicsTextItem return; } painter->save(); + if (m_shadowOverrides) { + if (m_shadowVisible != m_visible) { + m_shadowLayers = TitlerShadowV1::render(document(), + m_shadowStyle, + m_outlinePen, + m_visible, + defaultTextColor()); + m_shadowVisible = m_visible; + } + TitlerShadowV1::paint(painter, m_shadowLayers); + } TitlerOutline::paint(painter, document(), m_outlinePen, m_visible); { // The final reveal uses the same cells, glyphs and decorations as earlier frames. @@ -438,12 +464,30 @@ class Item : public QGraphicsTextItem } } } - paintDecorations(painter, cells); + if (m_shadowOverrides) { + // New selective-shadow titles use Qt's decoration placement for + // both the visible text and its alpha mask. Keep legacy frames. + painter->save(); + painter->setClipRect(TitlerShadowV1::decorationBounds(document(), m_outlinePen), + Qt::IntersectClip); + TitlerShadowV1::paintShadowDecorations(painter, + cells, + document()->defaultFont(), + defaultTextColor()); + painter->restore(); + } else { + paintDecorations(painter, cells); + } } painter->restore(); } private: + TitlerShadowV1::Style m_shadowStyle; + TitlerShadowV1::Layers m_shadowLayers; + QRectF m_shadowBounds; + int m_shadowVisible{-1}; + bool m_shadowOverrides{false}; QPen m_outlinePen{Qt::NoPen}; qreal m_outlineMargin{0}; QRectF m_inkBounds; diff --git a/src/modules/qt/richtextshadow.h b/src/modules/qt/richtextshadow.h new file mode 100644 index 000000000..2bd13b181 --- /dev/null +++ b/src/modules/qt/richtextshadow.h @@ -0,0 +1,779 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once + +#include "richtextgradient.h" +#include "richtextoutline.h" + +#include +#include +#include +#include +#include +#include + +// Shadows are independent character-format overrides of the legacy item style. +// Offsets use document coordinates, so no selection/region identifier is needed. +namespace TitlerShadowV1 { +enum Property { Enabled = QTextFormat::UserProperty + 5, Color, Blur, OffsetX, OffsetY }; +constexpr int MaximumBlur = 200; +constexpr int MaximumOffset = 10000; + +struct Style +{ + bool enabled{false}; + QColor color{0, 0, 0, 100}; + int blur{0}; + int x{0}; + int y{0}; + bool operator==(const Style &other) const + { + return enabled == other.enabled && color == other.color && blur == other.blur + && x == other.x && y == other.y; + } +}; + +inline bool valid(int property, const QVariant &value) +{ + if (property == Color) + return value.value().isValid(); + bool ok = false; + const int integer = value.toInt(&ok); + if (!ok) + return false; + if (property == Enabled) + return integer == 0 || integer == 1; + if (property == Blur) + return integer >= 0 && integer <= MaximumBlur; + return (property == OffsetX || property == OffsetY) && qAbs(qint64(integer)) <= MaximumOffset; +} + +inline Style effective(const QTextCharFormat &format, Style fallback) +{ + fallback.blur = qBound(0, fallback.blur, MaximumBlur); + fallback.x = qBound(-MaximumOffset, fallback.x, MaximumOffset); + fallback.y = qBound(-MaximumOffset, fallback.y, MaximumOffset); + if (!fallback.color.isValid()) + fallback.color = QColor(0, 0, 0, 100); + for (int property = Enabled; property <= OffsetY; ++property) { + if (!format.hasProperty(property) || !valid(property, format.property(property))) + continue; + const auto value = format.property(property); + switch (property) { + case Enabled: + fallback.enabled = value.toBool(); + break; + case Color: + fallback.color = value.value(); + break; + case Blur: + fallback.blur = value.toInt(); + break; + case OffsetX: + fallback.x = value.toInt(); + break; + case OffsetY: + fallback.y = value.toInt(); + break; + } + } + return fallback; +} + +inline bool hasOverrides(const QTextDocument *document) +{ + for (auto block = document->begin(); block.isValid(); block = block.next()) { + for (auto it = block.begin(); !it.atEnd(); ++it) { + const auto fragment = it.fragment(); + if (!fragment.isValid()) + continue; + for (int property = Enabled; property <= OffsetY; ++property) { + if (fragment.charFormat().hasProperty(property)) + return true; + } + } + } + return false; +} + +struct SelectionState +{ + Style style; + bool mixed{false}; +}; + +inline SelectionState selectionState(const QTextCursor &cursor, const Style &fallback) +{ + SelectionState result{effective(cursor.charFormat(), fallback), false}; + if (!cursor.hasSelection()) + return result; + bool first = true; + for (auto block = cursor.document()->findBlock(cursor.selectionStart()); + block.isValid() && block.position() < cursor.selectionEnd(); + block = block.next()) { + for (auto it = block.begin(); !it.atEnd(); ++it) { + const auto fragment = it.fragment(); + if (!fragment.isValid() || fragment.position() >= cursor.selectionEnd() + || fragment.position() + fragment.length() <= cursor.selectionStart()) + continue; + const auto style = effective(fragment.charFormat(), fallback); + if (first) { + result.style = style; + first = false; + } else { + result.mixed |= !(result.style == style); + } + } + } + return result; +} + +inline QDomElement save(QDomDocument &xml, const QTextDocument *document) +{ + if (!hasOverrides(document)) + return {}; + auto result = xml.createElement(QStringLiteral("richtext-shadows")); + result.setAttribute(QStringLiteral("version"), 1); + result.setAttribute(QStringLiteral("units"), QStringLiteral("utf16")); + result.setAttribute(QStringLiteral("characters"), document->characterCount() - 1); + result.setAttribute(QStringLiteral("text-sha256"), TitlerGradientV1::textHash(document)); + const char *names[] = {"enabled", "color", "blur", "x", "y"}; + for (auto block = document->begin(); block.isValid(); block = block.next()) { + for (auto it = block.begin(); !it.atEnd(); ++it) { + const auto fragment = it.fragment(); + if (!fragment.isValid()) + continue; + auto run = xml.createElement(QStringLiteral("run")); + bool any = false; + for (int property = Enabled; property <= OffsetY; ++property) { + const auto format = fragment.charFormat(); + if (!format.hasProperty(property) || !valid(property, format.property(property))) + continue; + const auto value = format.property(property); + run.setAttribute(QLatin1String(names[property - Enabled]), + property == Color ? value.value().name(QColor::HexArgb) + : QString::number(value.toInt())); + any = true; + } + if (any) { + run.setAttribute(QStringLiteral("start"), fragment.position()); + run.setAttribute(QStringLiteral("length"), fragment.length()); + result.appendChild(run); + } + } + } + return result; +} + +inline bool restore(const QDomElement &content, QTextDocument *document) +{ + const auto element = content.firstChildElement(QStringLiteral("richtext-shadows")); + if (element.isNull()) + return true; + bool ok = false; + const int total = document->characterCount() - 1; + if (element.attribute(QStringLiteral("version")) != QLatin1String("1") + || element.attribute(QStringLiteral("units")) != QLatin1String("utf16") + || element.attribute(QStringLiteral("characters")).toInt(&ok) != total || !ok + || element.attribute(QStringLiteral("text-sha256")) != TitlerGradientV1::textHash(document) + || !element.nextSiblingElement(QStringLiteral("richtext-shadows")).isNull()) + return false; + const auto plain = document->toPlainText(); + if (plain.size() != total) + return false; + const auto splits = [&plain, total](int position) { + return position > 0 && position < total && plain.at(position - 1).isHighSurrogate() + && plain.at(position).isLowSurrogate(); + }; + struct SavedRun + { + int start; + int length; + QTextCharFormat format; + }; + QVector runs; + const char *names[] = {"enabled", "color", "blur", "x", "y"}; + int previous = 0; + for (auto run = element.firstChildElement(); !run.isNull(); run = run.nextSiblingElement()) { + bool startOk = false, lengthOk = false; + const int start = run.attribute(QStringLiteral("start")).toInt(&startOk); + const int length = run.attribute(QStringLiteral("length")).toInt(&lengthOk); + if (run.tagName() != QLatin1String("run") || !startOk || !lengthOk || start < previous + || length <= 0 || length > total || start > total - length || splits(start) + || splits(start + length) || !run.firstChildElement().isNull()) + return false; + QTextCharFormat format; + bool any = false; + for (int property = Enabled; property <= OffsetY; ++property) { + const auto name = QLatin1String(names[property - Enabled]); + if (!run.hasAttribute(name)) + continue; + const QString text = run.attribute(name); + QVariant value; + if (property == Color) { + if (text.size() != 9 || text.at(0) != QLatin1Char('#')) + return false; + for (int i = 1; i < text.size(); ++i) { + const auto ch = text.at(i).toLower(); + if (!((ch >= QLatin1Char('0') && ch <= QLatin1Char('9')) + || (ch >= QLatin1Char('a') && ch <= QLatin1Char('f')))) + return false; + } + const uint rgba = text.mid(1).toUInt(&ok, 16); + if (!ok) + return false; + value = QColor::fromRgba(rgba); + } else { + const int integer = text.toInt(&ok); + if (!ok) + return false; + value = integer; + } + if (!valid(property, value)) + return false; + format.setProperty(property, value); + any = true; + } + if (!any) + return false; + runs.append({start, length, format}); + previous = start + length; + } + // Validate every run before modifying the document. + QTextCursor cursor(document); + cursor.beginEditBlock(); + for (const auto &run : runs) { + cursor.setPosition(run.start); + cursor.setPosition(run.start + run.length, QTextCursor::KeepAnchor); + cursor.mergeCharFormat(run.format); + } + cursor.endEditBlock(); + return true; +} + +struct Span +{ + int start; + int length; +}; +struct Group +{ + Style style; + QVector spans; +}; +struct Layer +{ + QImage image; + QPointF origin; +}; +using Layers = QVector; + +// A logical cursor at a bidi boundary can resolve to the neighboring visual +// run. Use the shaped interval, retaining cursor subdivisions for a shared +// ligature (whose whole glyph rectangle exceeds the selected characters). +inline QRectF decorationRect(const QTextLine &line, + int start, + int length, + const QList &runs, + const QPointF &origin) +{ + qreal a = line.cursorToX(start); + qreal z = line.cursorToX(start + length); + QRectF visual; + bool split = false; + for (const auto &run : runs) { + split |= run.flags().testFlag(QGlyphRun::SplitLigature); + const auto bounds = run.boundingRect(); + if (!bounds.isEmpty()) + visual = visual.isEmpty() ? bounds : visual.united(bounds); + } + if (!split && !visual.isEmpty()) { + a = visual.left(); + z = visual.right(); + } + return QRectF(origin.x() + std::min(a, z), + origin.y() + line.y(), + std::abs(z - a), + line.height()); +} + +inline QRectF decorationBounds(QTextDocument *document, const QPen &outline) +{ + // Match the visible text item's source bounds: accents can extend above + // the document rectangle, and Qt clips decoration fringes to these bounds. + const qreal margin = TitlerOutline::margin(document, outline); + return QRectF(QPointF(), document->size()) + .united(TitlerOutline::inkBounds(document)) + .adjusted(-margin, -margin, margin, margin); +} + +struct ShadowCell +{ + int start; + int end; + QRectF rect; + QRectF visibleRect; + qreal baseline; + int lineId; + QTextCharFormat format; + QRawFont physicalFont; + QRectF lineBounds; +}; + +inline QVector shadowCells(QTextDocument *document, + const Group &group, + int visible, + const QVector &gradients) +{ + QVector cells; + // Collect the final line, including the hidden suffix: a later fallback + // font must not change the underline metrics of already visible text. + for (QTextBlock block = document->begin(); block.isValid(); block = block.next()) { + const QTextLayout *layout = block.layout(); + if (!layout) { + continue; + } + const QString text = block.text(); + QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, text); + int start = 0; + for (int end = finder.toNextBoundary(); end >= 0; + start = end, end = finder.toNextBoundary()) { + for (const auto &part : TitlerGradientV1::layoutRanges(layout, start, end - start)) { + const QTextLine line = layout->lineForTextPosition(part.start); + if (!line.isValid() || end <= start) { + continue; + } + QTextCursor cursor(document); + cursor.setPosition(block.position() + start); + cursor.setPosition(block.position() + end, QTextCursor::KeepAnchor); + QTextCharFormat format = cursor.charFormat(); + for (const auto &gradient : gradients) { + if (block.position() + start >= gradient.start + && block.position() + start < gradient.start + gradient.length) { + format.setForeground(QBrush( + TitlerGradientV1::gradientFromString(gradient.data, gradient.bounds))); + break; + } + } + bool included = false; + for (const auto &span : group.spans) + included |= block.position() + start < span.start + span.length + && block.position() + end > span.start; + QRawFont physicalFont; + qreal largestAdvance = -1; + qreal baseline = line.y() + line.ascent(); + // Decorations belong to the advancing font in the shaped + // cluster. Zero-advance fallback marks do not gain a second + // cell-wide decoration. No shaping or positions are replaced. + const auto runs = line.glyphRuns(part.start, part.length); + for (const QGlyphRun &run : runs) { + const QRawFont raw = run.rawFont(); + if (!raw.isValid()) { + continue; + } + qreal advance = 0; + for (const QPointF &a : raw.advancesForGlyphIndexes(run.glyphIndexes())) { + advance += std::abs(a.x()); + } + if (!physicalFont.isValid() || advance > largestAdvance) { + physicalFont = raw; + largestAdvance = advance; + if (!run.positions().isEmpty()) + baseline = run.positions().first().y(); + } + } + if (!physicalFont.isValid()) { + // Tabs and other advance-only cells can have no glyph run. + physicalFont = QRawFont::fromFont( + format.font().resolve(document->defaultFont())); + } + const QPointF origin + = document->documentLayout()->blockBoundingRect(block).topLeft(); + const QRectF rect = decorationRect(line, part.start, part.length, runs, origin); + cells.append({block.position() + start, + block.position() + end, + rect, + included && block.position() + end <= visible ? rect : QRectF(), + origin.y() + baseline, + block.position() + line.textStart(), + format, + physicalFont, + line.naturalTextRect().translated(origin)}); + } + } + } + std::sort(cells.begin(), cells.end(), [](const ShadowCell &a, const ShadowCell &b) { + if (a.lineId != b.lineId) { + return a.lineId < b.lineId; + } + return a.rect.left() < b.rect.left(); + }); + return cells; +} + +inline void paintShadowBackgrounds(QPainter *painter, const QVector &cells) +{ + painter->save(); + for (const ShadowCell &cell : cells) { + const QBrush brush = cell.format.background(); + if (brush.style() != Qt::NoBrush && !cell.visibleRect.isEmpty()) { + painter->fillRect(cell.visibleRect.toAlignedRect(), brush); + } + } + painter->restore(); +} + +template +inline void paintShadowDecorations(QPainter *painter, + const Cells &cells, + const QFont &defaultFont, + const QColor &defaultColor) +{ + struct Decoration + { + int lineId; + qreal fullLeft; + qreal fullRight; + qreal left; + qreal right; + qreal y; + QPen pen; + }; + // QTBUG-134626 changed underline centering in Qt 6.8.4 and 6.9.1. + // Use the loaded Qt version: an older-built binary can load newer Qt. + static const bool underlineUsesPenWidth = [] { + const auto version = QLibraryInfo::version(); + return version >= QVersionNumber(6, 9, 1) + || (version >= QVersionNumber(6, 8, 4) && version < QVersionNumber(6, 9, 0)); + }(); + QVector underlines; + QVector strikes; + QVector overlines; + painter->save(); + painter->setBrush(Qt::NoBrush); + for (const auto &cell : cells) { + const QTextCharFormat &format = cell.format; + if (!format.fontUnderline() && !format.fontOverline() && !format.fontStrikeOut()) { + continue; + } + const QBrush brush = format.foreground().style() == Qt::NoBrush ? QBrush(defaultColor) + : format.foreground(); + const QFontMetricsF fallback(format.font().resolve(defaultFont), painter->device()); + const bool raw = cell.physicalFont.isValid(); + const qreal ascent = raw ? cell.physicalFont.ascent() : fallback.ascent(); + const qreal descent = raw ? cell.physicalFont.descent() : fallback.descent(); + const qreal thickness = raw ? cell.physicalFont.lineThickness() : fallback.lineWidth(); + const qreal underlinePosition = raw ? cell.physicalFont.underlinePosition() + : fallback.underlinePos(); + QPen pen(brush, thickness, Qt::SolidLine, Qt::FlatCap); + if (format.underlineColor().isValid()) { + pen.setColor(format.underlineColor()); + } + // Qt excludes trailing wrap whitespace from text decorations. + // The natural line rectangle also includes its alignment offset. + const QRectF fullRect = cell.rect.intersected(cell.lineBounds); + if (fullRect.isEmpty()) + continue; + const QRectF visibleRect = cell.visibleRect.intersected(fullRect); + const bool visible = !visibleRect.isEmpty() + && !(pen.brush().style() == Qt::SolidPattern + && pen.color().alpha() == 0); + const qreal left = visible ? std::floor(visibleRect.left()) : 0; + const qreal right = visible ? std::floor(visibleRect.right()) : 0; + const qreal fullLeft = std::floor(fullRect.left()); + const qreal fullRight = std::floor(fullRect.right()); + if (format.fontUnderline()) { + QPen underlinePen = pen; + if (underlineUsesPenWidth && !painter->testRenderHint(QPainter::Antialiasing)) { + underlinePen.setWidthF(std::max(qreal(1), qreal(qRound(thickness)))); + } + // A contiguous underline has common final-layout position and + // thickness, while each segment retains its foreground color. + const qreal halfWidth = underlineUsesPenWidth ? underlinePen.widthF() / 2 : qreal(0.5); + qreal offset = std::ceil(underlinePosition) + halfWidth; + if (underlinePosition <= descent) { + offset = std::min(offset, descent - halfWidth); + } + const auto style = format.underlineStyle(); + if (style >= QTextCharFormat::SingleUnderline + && style <= QTextCharFormat::DashDotDotLine) { + underlinePen.setStyle(static_cast(style)); + } + underlines.append( + {cell.lineId, fullLeft, fullRight, left, right, cell.baseline + offset, underlinePen}); + } + if (format.fontStrikeOut()) { + strikes.append( + {cell.lineId, fullLeft, fullRight, left, right, cell.baseline - ascent / 3, pen}); + } + if (format.fontOverline()) { + overlines.append( + {cell.lineId, fullLeft, fullRight, left, right, cell.baseline - ascent, pen}); + } + } + for (qsizetype start = 0; start < underlines.size();) { + qsizetype end = start + 1; + qreal y = underlines[start].y; + qreal width = underlines[start].pen.widthF(); + while (end < underlines.size() && underlines[end].lineId == underlines[start].lineId + && underlines[end - 1].fullRight == underlines[end].fullLeft) { + y = std::max(y, underlines[end].y); + width = std::max(width, underlines[end].pen.widthF()); + ++end; + } + for (qsizetype i = start; i < end; ++i) { + underlines[i].y = y; + underlines[i].pen.setWidthF(width); + } + start = end; + } + const auto draw = [painter](const QVector &segments) { + for (const auto &segment : segments) { + if (segment.right > segment.left) { + painter->setPen(segment.pen); + painter->drawLine(QLineF(segment.left, segment.y, segment.right, segment.y)); + } + } + }; + draw(underlines); + draw(strikes); + draw(overlines); + painter->restore(); +} + +inline QVector groups(QTextDocument *document, const Style &fallback, int visible = -1) +{ + QVector result; + const int total = document->characterCount() - 1; + visible = visible < 0 ? total : qBound(0, visible, total); + for (auto block = document->begin(); block.isValid() && block.position() < visible; + block = block.next()) { + for (auto it = block.begin(); !it.atEnd(); ++it) { + const auto fragment = it.fragment(); + if (!fragment.isValid() || fragment.position() >= visible) + continue; + const auto style = effective(fragment.charFormat(), fallback); + if (!style.enabled || style.color.alpha() == 0) + continue; + int index = 0; + while (index < result.size() && !(result.at(index).style == style)) + ++index; + if (index == result.size()) + result.append({style, {}}); + result[index].spans.append( + {fragment.position(), qMin(fragment.length(), visible - fragment.position())}); + } + } + return result; +} + +// Same four-pass exponential blur as the existing LGPL MLT title painter. +inline void blurImage(QImage &image, int radius) +{ + if (radius <= 0 || image.isNull()) + return; + const int table[] = {14, 10, 8, 6, 5, 5, 4, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2}; + const int alpha = radius > 17 ? 1 : table[radius - 1]; + const auto pass = [&](int startX, int startY, int dx, int dy, int count) { + auto *pixel = image.scanLine(startY) + startX * 4; + int channels[4] = {pixel[0] << 4, pixel[1] << 4, pixel[2] << 4, pixel[3] << 4}; + const int step = dy * image.bytesPerLine() + dx * 4; + for (int i = 1; i < count; ++i) { + pixel += step; + for (int c = 0; c < 4; ++c) + pixel[c] = uchar((channels[c] += ((pixel[c] << 4) - channels[c]) * alpha / 16) >> 4); + } + }; + for (int x = 0; x < image.width(); ++x) + pass(x, 0, 0, 1, image.height()); + for (int y = 0; y < image.height(); ++y) + pass(0, y, 1, 0, image.width()); + for (int x = 0; x < image.width(); ++x) + pass(x, image.height() - 1, 0, -1, image.height()); + for (int y = 0; y < image.height(); ++y) + pass(image.width() - 1, y, -1, 0, image.width()); +} + +inline QRectF sourceBounds(QTextDocument *document, const Span &span) +{ + QRectF result = TitlerGradientV1::rangeBounds(document, span.start, span.length); + const int end = span.start + span.length; + for (auto block = document->findBlock(span.start); block.isValid() && block.position() < end; + block = block.next()) { + const auto *layout = block.layout(); + if (!layout) + continue; + const QPointF origin = document->documentLayout()->blockBoundingRect(block).topLeft(); + const int a = qMax(span.start, block.position()) - block.position(); + const int b = qMin(end, block.position() + int(block.text().size())) - block.position(); + for (const auto &part : TitlerGradientV1::layoutRanges(layout, a, b - a)) { + for (int i = 0; i < layout->lineCount(); ++i) { + const auto line = layout->lineAt(i); + const int from = qMax(part.start, line.textStart()); + const int to = qMin(part.start + part.length, line.textStart() + line.textLength()); + if (from >= to) + continue; + for (const auto &run : line.glyphRuns(from, to - from)) { + // Metrics, in addition to ink, leave space for underlines and + // other decorations below a glyph's otherwise tight ink box. + const auto rect = run.boundingRect().translated(origin); + if (!rect.isEmpty()) + result = result.isEmpty() ? rect : result.united(rect); + const QRectF cell(rect.left(), + origin.y() + line.y(), + rect.width(), + line.height()); + if (!cell.isEmpty()) + result = result.isEmpty() ? cell : result.united(cell); + } + } + } + } + return result; +} + +inline QRectF bounds(QTextDocument *document, const Style &fallback, const QPen &outline) +{ + QRectF result; + const qreal margin = TitlerOutline::margin(document, outline) + 2; + document->documentLayout()->documentSize(); + for (const auto &group : groups(document, fallback)) { + for (const auto &span : group.spans) { + const QRectF ink = sourceBounds(document, span); + const qreal pad = margin + 2 * group.style.blur; + const auto rect + = ink.adjusted(-pad, -pad, pad, pad).translated(group.style.x, group.style.y); + result = result.isEmpty() ? rect : result.united(rect); + } + } + return result; +} + +inline Layers render(QTextDocument *document, + const Style &fallback, + const QPen &outline, + int visible = -1, + const QColor &defaultColor = Qt::white) +{ + Layers result; + const auto gradients = TitlerGradientV1::regions(document); + const auto resolved = groups(document, fallback, visible); + document->documentLayout()->documentSize(); + const QRectF decorationClip = decorationBounds(document, outline); + for (const auto &group : resolved) { + QRectF source; + for (const auto &span : group.spans) { + const auto ink = sourceBounds(document, span); + source = source.isEmpty() ? ink : source.united(ink); + } + if (source.isEmpty()) + continue; + const int pad = qCeil(TitlerOutline::margin(document, outline)) + 2 * group.style.blur + 2; + const QRect rect = source.toAlignedRect().adjusted(-pad, -pad, pad, pad); + // Each style has its own image. Large offsets do not allocate a giant + // mostly-empty union image, and hidden typewriter suffixes never paint. + QImage image(rect.size(), QImage::Format_ARGB32_Premultiplied); + if (image.isNull()) + continue; + image.fill(Qt::transparent); + QPainter painter(&image); + painter.setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing); + painter.translate(-rect.topLeft()); + const int total = document->characterCount() - 1; + const auto cells = shadowCells(document, group, visible < 0 ? total : visible, gradients); + for (int pass = 0; pass < 2; ++pass) { + if (pass == 1) + paintShadowBackgrounds(&painter, cells); + for (const auto &span : group.spans) { + const int end = span.start + span.length; + for (auto block = document->findBlock(span.start); + block.isValid() && block.position() < end; + block = block.next()) { + const auto *layout = block.layout(); + if (!layout) + continue; + const QPointF origin + = document->documentLayout()->blockBoundingRect(block).topLeft(); + for (auto it = block.begin(); !it.atEnd(); ++it) { + const auto fragment = it.fragment(); + const int a = qMax(span.start, fragment.position()); + const int b = qMin(end, fragment.position() + fragment.length()); + if (!fragment.isValid() || a >= b) + continue; + const auto format = fragment.charFormat(); + QBrush brush = format.foreground().style() == Qt::NoBrush + ? QBrush(defaultColor) + : format.foreground(); + for (const auto &gradient : gradients) { + if (a >= gradient.start && a < gradient.start + gradient.length) { + brush = QBrush( + TitlerGradientV1::gradientFromString(gradient.data, + gradient.bounds)); + break; + } + } + const QPen stroke = TitlerOutline::effectivePen(format, outline); + for (const auto &part : + TitlerGradientV1::layoutRanges(layout, a - block.position(), b - a)) { + for (int lineIndex = 0; lineIndex < layout->lineCount(); ++lineIndex) { + const auto line = layout->lineAt(lineIndex); + const int from = qMax(part.start, line.textStart()); + const int to = qMin(part.start + part.length, + line.textStart() + line.textLength()); + if (from >= to) + continue; + for (const auto &run : line.glyphRuns(from, to - from)) { + painter.save(); + if (run.flags().testFlag(QGlyphRun::SplitLigature)) + painter.setClipRect(run.boundingRect().translated(origin), + Qt::IntersectClip); + if (pass == 0 && stroke.style() != Qt::NoPen + && stroke.color().alpha() != 0) { + QPainterPath path; + path.setFillRule(Qt::WindingFill); + const auto glyphs = run.glyphIndexes(); + const auto positions = run.positions(); + for (int i = 0; i < qMin(glyphs.size(), positions.size()); + ++i) + path.addPath(run.rawFont() + .pathForGlyph(glyphs.at(i)) + .translated(origin + positions.at(i))); + painter.strokePath(path, stroke); + } else if (pass == 1 + && (brush.style() != Qt::SolidPattern + || brush.color().alpha() != 0)) { + // Explicit ranges keep unselected bitmap/color glyphs out + // of the mask, without recoloring or reflowing the source. + painter.setPen(QPen(brush, 0)); + QGlyphRun glyphs = run; + glyphs.setUnderline(false); + glyphs.setOverline(false); + glyphs.setStrikeOut(false); + painter.drawGlyphRun(origin, glyphs); + } + painter.restore(); + } + } + } + } + } + } + } + painter.setClipRect(decorationClip, Qt::IntersectClip); + paintShadowDecorations(&painter, cells, document->defaultFont(), defaultColor); + painter.setClipping(false); + painter.setCompositionMode(QPainter::CompositionMode_SourceIn); + painter.resetTransform(); + painter.fillRect(image.rect(), group.style.color); + painter.end(); + blurImage(image, group.style.blur); + result.append({image, rect.topLeft() + QPoint(group.style.x, group.style.y)}); + } + return result; +} + +inline void paint(QPainter *painter, const Layers &layers) +{ + for (const auto &layer : layers) + painter->drawImage(layer.origin, layer.image); +} +} // namespace TitlerShadowV1 diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index b0f711535..a657ba2a3 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -71,6 +71,10 @@ if(MOD_QT6 AND BUILD_TESTS_WITH_QT6) TEST_NAME richtextgradient LINK_LIBRARIES Qt6::Gui Qt6::Widgets Qt6::Xml ) + add_qt_test( + TEST_NAME richtextshadow + LINK_LIBRARIES Qt6::Gui Qt6::Widgets Qt6::Xml + ) endif() if(Kwalify_FOUND) diff --git a/src/tests/test_richtextshadow/test_richtextshadow.cpp b/src/tests/test_richtextshadow/test_richtextshadow.cpp new file mode 100644 index 000000000..2e34fa1ee --- /dev/null +++ b/src/tests/test_richtextshadow/test_richtextshadow.cpp @@ -0,0 +1,427 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +void configureDocument(QTextDocument &document, int start, Qt::Alignment alignment = Qt::AlignLeft) +{ + QFont font(QStringLiteral("DejaVu Sans")); + font.setPixelSize(64); + document.setDefaultFont(font); + document.setDocumentMargin(0); + document.setPlainText(QStringLiteral("MMMM XXXX")); + document.setTextWidth(600); + QTextCursor cursor(&document); + cursor.select(QTextCursor::Document); + QTextCharFormat white; + white.setForeground(QBrush(Qt::white)); + cursor.mergeCharFormat(white); + QTextBlockFormat block; + block.setAlignment(alignment); + cursor.mergeBlockFormat(block); + cursor.setPosition(start); + cursor.setPosition(start + 4, QTextCursor::KeepAnchor); + QTextCharFormat gradient; + gradient.setProperty(TitlerShadowV1::Enabled, true); + gradient.setProperty(TitlerShadowV1::Color, QColor(0, 255, 0)); + gradient.setProperty(TitlerShadowV1::Blur, 0); + gradient.setProperty(TitlerShadowV1::OffsetX, 0); + gradient.setProperty(TitlerShadowV1::OffsetY, 100); + cursor.mergeCharFormat(gradient); +} + +QImage renderDocument(QTextDocument &document) +{ + QImage image(1280, 720, QImage::Format_RGBA8888); + image.fill(Qt::transparent); + QPainter painter(&image); + painter.setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing); + painter.translate(40, 40); + TitlerShadowV1::paint(&painter, TitlerShadowV1::render(&document, {}, Qt::NoPen)); + TitlerOutline::paint(&painter, &document, Qt::NoPen); + // QGraphicsTextItem exposes ink outside the document rectangle (accents + // and their overline). Use that same source clip for this Qt reference. + const qreal margin = TitlerOutline::margin(&document, Qt::NoPen); + const QRectF bodyBounds = QRectF(QPointF(), document.size()) + .united(TitlerOutline::inkBounds(&document)) + .adjusted(-margin, -margin, margin, margin); + painter.setClipRect(bodyBounds, Qt::IntersectClip); + QAbstractTextDocumentLayout::PaintContext context; + context.clip = bodyBounds; + document.documentLayout()->draw(&painter, context); + painter.end(); + return image; +} + +QImage coloredPixels(const QImage &image) +{ + QImage selected(image.size(), QImage::Format_RGBA8888); + selected.fill(Qt::transparent); + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const QColor color = image.pixelColor(x, y); + if (color.alpha() > 0 && color.green() > color.red() + 60 + && color.green() > color.blue() + 60) + selected.setPixelColor(x, y, color); + } + } + return selected; +} + +QDomDocument titleXml(QTextDocument &document, Qt::Alignment alignment, bool animate = false) +{ + QDomDocument xml; + auto root = xml.createElement(QStringLiteral("kdenlivetitle")); + root.setAttribute(QStringLiteral("width"), 1280); + root.setAttribute(QStringLiteral("height"), 720); + root.setAttribute(QStringLiteral("out"), 299); + xml.appendChild(root); + auto item = xml.createElement(QStringLiteral("item")); + item.setAttribute(QStringLiteral("type"), QStringLiteral("QGraphicsTextItem")); + item.setAttribute(QStringLiteral("z-index"), 0); + root.appendChild(item); + auto position = xml.createElement(QStringLiteral("position")); + position.setAttribute(QStringLiteral("x"), 40); + position.setAttribute(QStringLiteral("y"), 40); + auto transform = xml.createElement(QStringLiteral("transform")); + transform.appendChild(xml.createTextNode(QStringLiteral("1,0,0,0,1,0,0,0,1"))); + position.appendChild(transform); + item.appendChild(position); + auto content = xml.createElement(QStringLiteral("content")); + content.setAttribute(QStringLiteral("font"), QStringLiteral("DejaVu Sans")); + content.setAttribute(QStringLiteral("font-pixel-size"), 64); + content.setAttribute(QStringLiteral("font-weight"), int(QFont::Normal)); + content.setAttribute(QStringLiteral("font-color"), QStringLiteral("255,255,255,255")); + content.setAttribute(QStringLiteral("font-outline"), 0); + content.setAttribute(QStringLiteral("font-outline-color"), QStringLiteral("0,0,0,255")); + content.setAttribute(QStringLiteral("box-width"), 600); + content.setAttribute(QStringLiteral("box-height"), document.size().height()); + content.setAttribute(QStringLiteral("preferred-width"), 600); + content.setAttribute(QStringLiteral("alignment"), int(alignment)); + content.setAttribute(QStringLiteral("typewriter"), + animate ? QStringLiteral("1;2;1;0;37") : QStringLiteral("0;2;1;0;37")); + content.appendChild(xml.createTextNode(document.toPlainText())); + auto rich = xml.createElement(QStringLiteral("richtext")); + rich.setAttribute(QStringLiteral("format"), QStringLiteral("qt-html-v1")); + rich.appendChild(xml.createTextNode(document.toHtml())); + content.appendChild(rich); + content.appendChild(TitlerGradientV1::save(xml, &document)); + content.appendChild(TitlerOutline::save(xml, &document)); + content.appendChild(TitlerShadowV1::save(xml, &document)); + content.appendChild(TitlerSpacingV1::save(xml, &document)); + item.appendChild(content); + auto background = xml.createElement(QStringLiteral("background")); + background.setAttribute(QStringLiteral("color"), QStringLiteral("0,0,0,0")); + root.appendChild(background); + return xml; +} + +void setTitle(Mlt::Producer &producer, const QDomDocument &xml) +{ + producer.set("xmldata", xml.toByteArray().constData()); + producer.set("length", 300); + producer.set_in_and_out(0, 299); + producer.set("force_reload", 1); +} + +void saveFailureImages(const QImage &expected, const QImage &actual, const QDomDocument &xml) +{ + if (expected == actual) + return; + const QString output = qEnvironmentVariable("SHADOW_TEST_ARTIFACTS"); + if (output.isEmpty()) + return; + const QDir directory(output); + if (!QDir().mkpath(output)) { + qWarning() << "Cannot create shadow test artifact directory" << output; + return; + } + const QString name = QString::fromLatin1(QTest::currentTestFunction()) + QLatin1Char('-') + + QString::fromLatin1(QTest::currentDataTag()); + expected.save(directory.filePath(name + QStringLiteral("-expected.png"))); + actual.save(directory.filePath(name + QStringLiteral("-actual.png"))); + QFile source(directory.filePath(name + QStringLiteral(".xml"))); + if (source.open(QIODevice::WriteOnly)) + source.write(xml.toByteArray()); + qWarning() << "Shadow frame comparison artifacts:" << directory.filePath(name); +} + +QImage renderFrame(Mlt::Producer &producer, int position) +{ + producer.seek(position); + std::unique_ptr frame(producer.get_frame()); + if (!frame || !frame->is_valid()) + return {}; + mlt_image_format format = mlt_image_rgba; + int width = 1280, height = 720; + auto *data = frame->get_image(format, width, height); + if (!data || format != mlt_image_rgba || width != 1280 || height != 720) + return {}; + return QImage(data, width, height, width * 4, QImage::Format_RGBA8888).copy(); +} +} // namespace + +class TestRichTextShadow : public QObject +{ + Q_OBJECT +private Q_SLOTS: + void initTestCase() + { + auto *repository = Mlt::Factory::init(); + QVERIFY(repository); + std::unique_ptr metadata( + repository->metadata(mlt_service_producer_type, "kdenlivetitle")); + QVERIFY(metadata && metadata->get_double("version") >= 9); + } + + void selectiveRendering_data() + { + QTest::addColumn("start"); + QTest::addColumn("blur"); + QTest::addColumn("alignment"); + for (int start : {0, 5}) + for (int blur : {0, 3, 12}) + for (auto alignment : {Qt::AlignLeft, Qt::AlignHCenter, Qt::AlignRight}) + QTest::newRow( + qPrintable(QString("%1-%2-%3").arg(start).arg(blur).arg(int(alignment)))) + << start << blur << int(alignment); + } + void selectiveRendering() + { + QFETCH(int, start); + QFETCH(int, blur); + QFETCH(int, alignment); + QTextDocument document; + configureDocument(document, start, Qt::Alignment(alignment)); + QTextCursor cursor(&document); + cursor.setPosition(start); + cursor.setPosition(start + 4, QTextCursor::KeepAnchor); + QTextCharFormat format; + format.setProperty(TitlerShadowV1::Blur, blur); + cursor.mergeCharFormat(format); + const auto expected = renderDocument(document); + const auto mask = coloredPixels(expected); + bool found = false; + const qreal boundary = 40 + document.begin().layout()->lineAt(0).cursorToX(5); + for (int y = 120; y < mask.height(); ++y) + for (int x = 0; x < mask.width(); ++x) { + if (!qAlpha(mask.pixel(x, y))) + continue; + found = true; + QVERIFY(start == 0 ? x <= boundary + 2 * blur + 3 : x >= boundary - 2 * blur - 3); + } + QVERIFY(found); + Mlt::Profile profile("atsc_720p_25"); + Mlt::Producer producer(profile, "kdenlivetitle", ""); + QVERIFY(producer.is_valid()); + setTitle(producer, titleXml(document, Qt::Alignment(alignment))); + QCOMPARE(renderFrame(producer, 240), expected); + } + void multipleStylesAndUnicode_data() + { + QTest::addColumn("text"); + QTest::addColumn("decoration"); + QTest::newRow("multiple-sizes-italic-spacing-outline") + << QString("Hello amazing world") << 3; + QTest::newRow("Arabic-RTL") << QString::fromUtf8("ABC مرحبا بالعالم XYZ") << 0; + QTest::newRow("Arabic-underline") << QString::fromUtf8("ABC مرحبا بالعالم XYZ") << 1; + QTest::newRow("Arabic-strike-overline") << QString::fromUtf8("ABC مرحبا بالعالم XYZ") << 2; + QTest::newRow("emoji-underline") << QString::fromUtf8("Ä office 🙂 ffi") << 1; + QTest::newRow("emoji-strike-overline") << QString::fromUtf8("Ä office 🙂 ffi") << 2; + QTest::newRow("combining-emoji-ligature") << QString::fromUtf8("Ä office 🙂 ffi") << 0; + QTest::newRow("multiline") << QString("MMMM\nXXX yyyy") << 0; + QTest::newRow("underline") << QString("MMMM XXXX") << 1; + QTest::newRow("mixed-size-underlines") << QString("Hello amazing world") << 5; + QTest::newRow("strike-overline") << QString("MMMM XXXX") << 2; + QTest::newRow("transparent-gradient") << QString("MMMM XXXX") << 4; + } + void multipleStylesAndUnicode() + { + QFETCH(QString, text); + QFETCH(int, decoration); + QTextDocument document; + configureDocument(document, 0); + document.setPlainText(text); + QTextCursor cursor(&document); + cursor.select(QTextCursor::Document); + QTextCharFormat format; + format.setForeground(Qt::white); + if (decoration == 1 || decoration == 5) + format.setFontUnderline(true); + if (decoration == 2) { + format.setFontStrikeOut(true); + format.setFontOverline(true); + } + cursor.mergeCharFormat(format); + if (decoration == 3 || decoration == 5) { + cursor.setPosition(6); + cursor.setPosition(13, QTextCursor::KeepAnchor); + QTextCharFormat mixed; + mixed.setProperty(QTextFormat::FontPixelSize, 80); + mixed.setFontWeight(QFont::Bold); + mixed.setFontItalic(true); + mixed.setFontLetterSpacingType(QFont::AbsoluteSpacing); + mixed.setFontLetterSpacing(2.5); + mixed.setProperty(TitlerOutline::WidthProperty, 6.0); + mixed.setProperty(TitlerOutline::ColorProperty, QColor(Qt::black)); + cursor.mergeCharFormat(mixed); + } + if (decoration == 4) { + cursor.select(QTextCursor::Document); + QTextCharFormat gradient; + gradient.setProperty(TitlerGradientV1::Property, "#00ffffff;#ffffffff;0;100;0"); + cursor.mergeCharFormat(gradient); + } + cursor.select(QTextCursor::Document); + QTextCharFormat shadow; + shadow.setProperty(TitlerShadowV1::Enabled, true); + shadow.setProperty(TitlerShadowV1::Color, QColor(0, 255, 0)); + shadow.setProperty(TitlerShadowV1::OffsetY, 100); + cursor.mergeCharFormat(shadow); + // Adjacent independent styles exercise separate negative/positive offset masks. + cursor.setPosition(0); + cursor.setPosition(1, QTextCursor::KeepAnchor); + shadow = QTextCharFormat(); + shadow.setProperty(TitlerShadowV1::Color, QColor(0, 0, 255)); + shadow.setProperty(TitlerShadowV1::OffsetX, -20); + shadow.setProperty(TitlerShadowV1::Blur, 3); + cursor.mergeCharFormat(shadow); + document.documentLayout()->documentSize(); + TitlerGradientV1::applyBrushes(&document, 600, int(document.size().height())); + const auto before = document.toHtml(); + const int undo = document.availableUndoSteps(); + const auto expected = renderDocument(document); + Mlt::Profile profile("atsc_720p_25"); + Mlt::Producer producer(profile, "kdenlivetitle", ""); + const auto xml = titleXml(document, Qt::AlignLeft); + setTitle(producer, xml); + const auto exported = renderFrame(producer, 240); + saveFailureImages(expected, exported, xml); + QCOMPARE(exported, expected); + QCOMPARE(document.toHtml(), before); + QCOMPARE(document.availableUndoSteps(), undo); + } + void persistenceAndAtomicValidation() + { + QTextDocument document; + configureDocument(document, 5); + QDomDocument xml; + auto content = xml.createElement("content"); + xml.appendChild(content); + content.appendChild(TitlerShadowV1::save(xml, &document)); + QTextDocument restored; + restored.setDocumentMargin(0); + restored.setHtml(document.toHtml()); + restored.setTextWidth(600); + QVERIFY(TitlerShadowV1::restore(content, &restored)); + QCOMPARE(renderDocument(restored), renderDocument(document)); + const auto before = restored.toHtml(); + const auto properties = TitlerShadowV1::save(xml, &restored); + auto bad = content.firstChildElement("richtext-shadows"); + auto second = bad.firstChildElement("run").cloneNode().toElement(); + bad.appendChild(second); + QVERIFY(!TitlerShadowV1::restore(content, &restored)); + QCOMPARE(restored.toHtml(), before); + QCOMPARE(TitlerShadowV1::save(xml, &restored).firstChildElement("run").attribute("start"), + properties.firstChildElement("run").attribute("start")); + QTextDocument plain; + plain.setPlainText("legacy"); + QVERIFY(TitlerShadowV1::save(xml, &plain).isNull()); + } + void legacyFallbackAndSelectedDisable() + { + QTextDocument document; + configureDocument(document, 0); + QTextCursor cursor(&document); + cursor.select(QTextCursor::Document); + QTextCharFormat disabled; + disabled.setProperty(TitlerShadowV1::Enabled, false); + cursor.mergeCharFormat(disabled); + cursor.setPosition(5); + cursor.setPosition(9, QTextCursor::KeepAnchor); + QTextCharFormat enabled; + enabled.setProperty(TitlerShadowV1::Enabled, true); + cursor.mergeCharFormat(enabled); + const TitlerShadowV1::Style fallback{true, QColor(0, 255, 0), 0, 0, 100}; + const auto layers = TitlerShadowV1::render(&document, fallback, Qt::NoPen); + QVERIFY(!layers.isEmpty()); + QImage image(1280, 720, QImage::Format_RGBA8888); + image.fill(Qt::transparent); + { + QPainter painter(&image); + painter.translate(40, 40); + TitlerShadowV1::paint(&painter, layers); + } + const qreal boundary = 40 + document.begin().layout()->lineAt(0).cursorToX(5); + bool found = false; + for (int y = 0; y < image.height(); ++y) + for (int x = 0; x < image.width(); ++x) + if (qAlpha(image.pixel(x, y))) { + found = true; + QVERIFY(x >= boundary - 1); + } + QVERIFY(found); + } + void typewriterSeeking() + { + Mlt::Profile profile("atsc_720p_25"); + for (int start : {0, 5}) { + QTextDocument document; + configureDocument(document, start, Qt::AlignRight); + const auto expected = renderDocument(document); + const auto xml = titleXml(document, Qt::AlignRight); + for (int mode : {1, 2, 3}) { + auto animatedXml = titleXml(document, Qt::AlignRight, true); + animatedXml.documentElement() + .firstChildElement("item") + .firstChildElement("content") + .setAttribute("typewriter", QString("1;2;%1;0;37").arg(mode)); + Mlt::Producer native(profile, "kdenlivetitle", ""); + setTitle(native, animatedXml); + Mlt::Producer external(profile, "kdenlivetitle", ""); + setTitle(external, xml); + Mlt::Filter effect(profile, "typewriter"); + QVERIFY(effect.is_valid()); + effect.set("macro_type", mode); + effect.set("step_length", 2); + effect.set("step_sigma", 0); + effect.set("random_seed", 37); + QCOMPARE(external.attach(effect), 0); + for (auto *producer : {&native, &external}) { + const QByteArray before(producer->get("xmldata")); + const auto first = renderFrame(*producer, 0); + QVERIFY(!first.isNull()); + if (start == 5 && mode != 3) { + const auto mask = coloredPixels(first); + bool found = false; + for (int y = 0; y < mask.height(); ++y) + for (int x = 0; x < mask.width(); ++x) + found |= qAlpha(mask.pixel(x, y)) > 0; + QVERIFY(!found); + } + QCOMPARE(renderFrame(*producer, 240), expected); + QCOMPARE(renderFrame(*producer, 0), first); + QCOMPARE(QByteArray(producer->get("xmldata")), before); + } + } + } + } +}; +QTEST_MAIN(TestRichTextShadow) +#include "test_richtextshadow.moc" From 901be7985c535062eb7222d41ba3f65e719e19f2 Mon Sep 17 00:00:00 2001 From: klg90 <284782940+klg90@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:47:23 -0400 Subject: [PATCH 3/4] qt: make selective title shadows and tests portable Preserve selective gradient and shadow rendering across platforms, keep backend-dependent tests explicit, and complete source attribution. Validated locally with stock Qt 6.11.2 before publication preparation. Fresh post-rebase Linux validation is required before pushing. --- src/modules/qt/richtextshadow.h | 5 +- src/tests/CMakeLists.txt | 4 ++ .../test_richtextgradient.cpp | 5 +- .../test_richtextshadow.cpp | 57 +++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/modules/qt/richtextshadow.h b/src/modules/qt/richtextshadow.h index 2bd13b181..46661d546 100644 --- a/src/modules/qt/richtextshadow.h +++ b/src/modules/qt/richtextshadow.h @@ -1,3 +1,4 @@ +// SPDX-FileCopyrightText: 2026 klg90 <284782940+klg90@users.noreply.github.com> // SPDX-License-Identifier: LGPL-2.1-or-later #pragma once @@ -671,7 +672,9 @@ inline Layers render(QTextDocument *document, const QRect rect = source.toAlignedRect().adjusted(-pad, -pad, pad, pad); // Each style has its own image. Large offsets do not allocate a giant // mostly-empty union image, and hidden typewriter suffixes never paint. - QImage image(rect.size(), QImage::Format_ARGB32_Premultiplied); + // A shadow needs grayscale coverage, independent of the screen subpixel layout. + // ARGB32_Premultiplied enables ClearType in the Windows raster engine. + QImage image(rect.size(), QImage::Format_RGBA8888_Premultiplied); if (image.isNull()) continue; image.fill(Qt::transparent); diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index a657ba2a3..22ab36b58 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -75,6 +75,10 @@ if(MOD_QT6 AND BUILD_TESTS_WITH_QT6) TEST_NAME richtextshadow LINK_LIBRARIES Qt6::Gui Qt6::Widgets Qt6::Xml ) + set_property( + TEST "QtTest:richtextgradient" "QtTest:richtextshadow" + APPEND PROPERTY ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ) endif() if(Kwalify_FOUND) diff --git a/src/tests/test_richtextgradient/test_richtextgradient.cpp b/src/tests/test_richtextgradient/test_richtextgradient.cpp index ed2078830..e3039d31d 100644 --- a/src/tests/test_richtextgradient/test_richtextgradient.cpp +++ b/src/tests/test_richtextgradient/test_richtextgradient.cpp @@ -1,3 +1,4 @@ +// SPDX-FileCopyrightText: 2026 klg90 <284782940+klg90@users.noreply.github.com> // SPDX-License-Identifier: LGPL-2.1-or-later #include @@ -161,9 +162,9 @@ private Q_SLOTS: QVERIFY(repository); std::unique_ptr metadata( repository->metadata(mlt_service_producer_type, "kdenlivetitle")); - QVERIFY2(metadata && metadata->get_double("version") >= 8, + QVERIFY2(metadata && metadata->get_double("version") >= 9, "The tested Qt module must provide the rich-text kdenlivetitle producer (version " - ">= 8)"); + ">= 9)"); } void selectiveExport_data() diff --git a/src/tests/test_richtextshadow/test_richtextshadow.cpp b/src/tests/test_richtextshadow/test_richtextshadow.cpp index 2e34fa1ee..b33624af2 100644 --- a/src/tests/test_richtextshadow/test_richtextshadow.cpp +++ b/src/tests/test_richtextshadow/test_richtextshadow.cpp @@ -1,3 +1,4 @@ +// SPDX-FileCopyrightText: 2026 klg90 <284782940+klg90@users.noreply.github.com> // SPDX-License-Identifier: LGPL-2.1-or-later #include @@ -190,6 +191,62 @@ private Q_SLOTS: QVERIFY(metadata && metadata->get_double("version") >= 9); } + void nativeAlphaAcrossRasterTargets() + { + QTextDocument document; + configureDocument(document, 0); + QTextCursor cursor(&document); + cursor.select(QTextCursor::Document); + QTextCharFormat format; + format.setProperty(TitlerShadowV1::Enabled, false); + format.setProperty(TitlerShadowV1::Color, QColor(0, 255, 0)); + format.setProperty(TitlerShadowV1::Blur, 0); + format.setProperty(TitlerShadowV1::OffsetX, 0); + format.setProperty(TitlerShadowV1::OffsetY, 100); + cursor.mergeCharFormat(format); + // This is the native QTextDocument result in RGBA, the video raster format. + const QImage body = renderDocument(document); + QImage tinted = body; + { + QPainter painter(&tinted); + painter.setCompositionMode(QPainter::CompositionMode_SourceIn); + painter.fillRect(tinted.rect(), Qt::green); + } + // Only Enabled changes after the native reference, preserving format boundaries. + format = QTextCharFormat(); + format.setProperty(TitlerShadowV1::Enabled, true); + cursor.mergeCharFormat(format); + const auto layers = TitlerShadowV1::render(&document, {}, Qt::NoPen); + QVERIFY(!layers.isEmpty()); + for (const auto rasterFormat : {QImage::Format_RGBA8888, + QImage::Format_RGBA8888_Premultiplied, + QImage::Format_ARGB32_Premultiplied}) { + QImage expected(body.size(), rasterFormat); + expected.fill(Qt::transparent); + { + QPainter painter(&expected); + painter.drawImage(0, 100, tinted); + } + QImage actual(body.size(), rasterFormat); + actual.fill(Qt::transparent); + { + QPainter painter(&actual); + painter.translate(40, 40); + TitlerShadowV1::paint(&painter, layers); + } + int covered = 0; + int mismatched = 0; + for (int y = 0; y < actual.height(); ++y) + for (int x = 0; x < actual.width(); ++x) { + const int expectedAlpha = qAlpha(expected.pixel(x, y)); + covered += expectedAlpha > 0; + mismatched += qAlpha(actual.pixel(x, y)) != expectedAlpha; + } + QVERIFY(covered > 100); + QCOMPARE(mismatched, 0); + } + } + void selectiveRendering_data() { QTest::addColumn("start"); From d1863a848254d309f3e5c9daf608ce9868aa8b0b Mon Sep 17 00:00:00 2001 From: klg90 <284782940+klg90@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:16:24 -0400 Subject: [PATCH 4/4] qt: avoid duplicate title shadow value count check --- src/modules/qt/kdenlivetitle_wrapper.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/modules/qt/kdenlivetitle_wrapper.cpp b/src/modules/qt/kdenlivetitle_wrapper.cpp index fc736c79a..74c7140ff 100644 --- a/src/modules/qt/kdenlivetitle_wrapper.cpp +++ b/src/modules/qt/kdenlivetitle_wrapper.cpp @@ -612,14 +612,14 @@ void loadFromXml(producer_ktitle self, values.at(2).toInt(), values.at(3).toInt(), values.at(4).toInt()}); - } - if (values.count() >= 5 && values.at(0).toInt() != 0 - && !TitlerShadowV1::hasOverrides(txt->document())) { - auto *shadow = new QGraphicsDropShadowEffect(); - shadow->setColor(QColor(values.at(1))); - shadow->setBlurRadius(values.at(2).toDouble()); - shadow->setOffset(values.at(3).toDouble(), values.at(4).toDouble()); - txt->setGraphicsEffect(shadow); + if (values.at(0).toInt() != 0 + && !TitlerShadowV1::hasOverrides(txt->document())) { + auto *shadow = new QGraphicsDropShadowEffect(); + shadow->setColor(QColor(values.at(1))); + shadow->setBlurRadius(values.at(2).toDouble()); + shadow->setOffset(values.at(3).toDouble(), values.at(4).toDouble()); + txt->setGraphicsEffect(shadow); + } } }