diff --git a/src/modules/qt/kdenlivetitle_wrapper.cpp b/src/modules/qt/kdenlivetitle_wrapper.cpp index d70184361..74c7140ff 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,16 +591,35 @@ 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 (!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) { - 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.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.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); + } } } 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/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/modules/qt/richtextshadow.h b/src/modules/qt/richtextshadow.h new file mode 100644 index 000000000..46661d546 --- /dev/null +++ b/src/modules/qt/richtextshadow.h @@ -0,0 +1,782 @@ +// SPDX-FileCopyrightText: 2026 klg90 <284782940+klg90@users.noreply.github.com> +// 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. + // 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); + 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 10555caab..22ab36b58 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -65,6 +65,22 @@ 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 + ) + add_qt_test( + 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) 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..e3039d31d --- /dev/null +++ b/src/tests/test_richtextgradient/test_richtextgradient.cpp @@ -0,0 +1,307 @@ +// SPDX-FileCopyrightText: 2026 klg90 <284782940+klg90@users.noreply.github.com> +// 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") >= 9, + "The tested Qt module must provide the rich-text kdenlivetitle producer (version " + ">= 9)"); + } + + 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" diff --git a/src/tests/test_richtextshadow/test_richtextshadow.cpp b/src/tests/test_richtextshadow/test_richtextshadow.cpp new file mode 100644 index 000000000..b33624af2 --- /dev/null +++ b/src/tests/test_richtextshadow/test_richtextshadow.cpp @@ -0,0 +1,484 @@ +// SPDX-FileCopyrightText: 2026 klg90 <284782940+klg90@users.noreply.github.com> +// 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 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"); + 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"