#include "BrushFixtures.h" #include "Document/EditorSession.h" #include "Rendering/TextLayout.h" // Text laid out as TextKit lays it: fonts, boxes, lines. namespace { LayerTextStyle styled(const QString &content, double size = 72) { LayerTextStyle style; style.content = content; style.fontSize = size; return style; } // The rows and columns where a text image has ink. QRect inked(const QImage &image) { QRect found; for (int y = 0; y < image.height(); ++y) { for (int x = 0; x < image.width(); ++x) { if (alpha(image, x, y) > 64) found |= QRect(x, y, 1, 1); } } return found; } } class TextLayoutTests : public QObject { Q_OBJECT private slots: void facesAreFoundByPostScriptName(); void pointTextMeasuresItsLines(); void aBoxWrapsAndDropsWhatDoesNotFit(); void linesAlignInTheirBox(); void baselinesSitAtTheBottomOfFixedLines(); void imagesKeepSwiftsLimits(); void aBoxUnderItsPaddingHoldsAPixel(); void glyphsTakeTheStyleColour(); void paragraphsBreakWhereCocoaBreaksThem(); void tabsStopEvery28Points(); void linesStopWhereTheBoxDoes(); void aSelectionFollowsItsRunsToTheLinesEdge(); }; void TextLayoutTests::facesAreFoundByPostScriptName() { LayerTextStyle style = styled(QStringLiteral("Text"), 36.5); style.fontName = QStringLiteral("DejaVuSans-Bold"); style.tracking = 4; const QFont bold = TextLayout::font(style); QCOMPARE(bold.family(), QString("DejaVu Sans")); QCOMPARE(bold.styleName(), QString("Bold")); QCOMPARE(QFontInfo(bold).pixelSize(), 37); // A whole size is that many pixels, as Swift's points. style.fontSize = 40; QFont pixels = TextLayout::font(style); const double advance = QFontMetricsF(pixels).horizontalAdvance(QStringLiteral("Hamburgefons")); pixels.setPixelSize(40); QVERIFY(std::abs(QFontMetricsF(pixels).horizontalAdvance(QStringLiteral("Hamburgefons")) - advance) < 0.01); // Qt draws faces in whole pixels: a fraction rounds. style.fontSize = 36.4; QCOMPARE(QFontInfo(TextLayout::font(style)).pixelSize(), 36); style.fontSize = 36.6; QCOMPARE(QFontInfo(TextLayout::font(style)).pixelSize(), 37); // Swift always sets a kern: no pairs, the tracking added. QVERIFY(!bold.kerning()); QCOMPARE(bold.letterSpacingType(), QFont::AbsoluteSpacing); QCOMPARE(bold.letterSpacing(), 4.0); QCOMPARE(bold.hintingPreference(), QFont::PreferNoHinting); // Not installed: Qt's match, where macOS takes its own. style.fontName = QStringLiteral("Helvetica"); QCOMPARE(TextLayout::font(style).family(), QString("Helvetica")); QCOMPARE(TextLayout::font(style).styleName(), QString()); // Distributions split DejaVu differently: its faces, once each. const QStringList names = TextLayout::availableFonts(); QVERIFY(names.contains(QStringLiteral("DejaVuSans")) && names.contains(QStringLiteral("DejaVuSerif-Bold"))); QCOMPARE(QSet(names.begin(), names.end()).size(), names.size()); QStringList sorted = names; sorted.sort(); QCOMPARE(names, sorted); } void TextLayoutTests::pointTextMeasuresItsLines() { // One line of 72: 86.4 high, rounded up, padded. const QSizeF one = EditorSession::textBoxSize(styled(QStringLiteral("Text"))); QCOMPARE(one.height(), 111.0); const TextLines lines(styled(QStringLiteral("Text")), QSizeF(100'000, 100'000)); QCOMPARE(one.width(), std::ceil(lines.usedSize().width() + 24 + 7.2)); QVERIFY(lines.usedSize().width() > 100 && !lines.overflows()); QCOMPARE(EditorSession::textBoxSize(styled(QStringLiteral("A\nB"))).height(), 197.0); // The widest line sets the width, wherever it sits. QCOMPARE(EditorSession::textBoxSize(styled(QStringLiteral("WWWW\nI"))).width(), EditorSession::textBoxSize(styled(QStringLiteral("WWWW"))).width()); // A final newline adds no line; two newlines make two. QCOMPARE(EditorSession::textBoxSize(styled(QStringLiteral("Text\n"))), one); QCOMPARE(EditorSession::textBoxSize(styled(QStringLiteral("\n\n"))).height(), 197.0); // Empty text keeps a line and a caret's room. QCOMPARE(EditorSession::textBoxSize(styled(QString())), QSizeF(32, 111)); QCOMPARE(EditorSession::textBoxSize(styled(QString(), 2)), QSizeF(25, 27)); // Tracking widens by its amount a letter. LayerTextStyle spaced = styled(QStringLiteral("Text")); spaced.tracking = 10; QVERIFY(std::abs(EditorSession::textBoxSize(spaced).width() - one.width() - 40) <= 1); LayerTextStyle led = styled(QStringLiteral("Text")); led.leading = 100; QCOMPARE(EditorSession::textBoxSize(led).height(), 124.0); QCOMPARE(EditorSession::textImage(styled(QStringLiteral("Text"))).size(), one.toSize()); // Point text never wraps short of 100,000 pixels. const QSizeF wide = EditorSession::textBoxSize(styled(QStringLiteral("Hamburgefonstiv ").repeated(8))); QVERIFY(wide.width() > 3'000 && wide.height() == 111); } void TextLayoutTests::aBoxWrapsAndDropsWhatDoesNotFit() { LayerTextStyle style = styled(QStringLiteral("Words that wrap inside the box and keep on going"), 24); style.boxSize = QSizeF(160, 120); QCOMPARE(EditorSession::textBoxSize(style), QSizeF(160, 120)); const QImage image = EditorSession::textImage(style); QCOMPARE(image.size(), QSize(160, 120)); // 96 pixels hold three 28.8 lines; the rest drops. const TextLines lines(style, QSizeF(136, 96)); QCOMPARE(lines.usedSize().height(), 3 * 28.8); QVERIFY(lines.overflows() && lines.usedSize().width() <= 136); // Half a pixel short of three lines holds two. QCOMPARE(TextLines(style, QSizeF(136, 3 * 28.8 - 0.5)).usedSize().height(), 2 * 28.8); const QRect ink = inked(image); QVERIFY2(ink.left() >= 12 && ink.right() < 148 && ink.bottom() < 12 + 3 * 28.8 + 1, qPrintable(QString("%1 %2 %3").arg(ink.left()).arg(ink.right()).arg(ink.bottom()))); // Room for all: nothing falls past the bottom. style.boxSize = QSizeF(160, 400); QVERIFY(!TextLines(style, QSizeF(136, 376)).overflows()); // A word longer than the line breaks anywhere. style.content = QStringLiteral("Unbreakablewordthatislong"); const TextLines broken(style, QSizeF(136, 376)); QVERIFY(broken.lineCount() > 1); // With no space, its line ends where the next begins. QCOMPARE(broken.lineEnd(0), broken.lineStart(1)); } void TextLayoutTests::linesAlignInTheirBox() { LayerTextStyle style = styled(QStringLiteral("WWWWW\nI"), 40); style.boxSize = QSizeF(400, 200); const auto lineInk = [&](int top, int bottom) { const QImage image = EditorSession::textImage(style); return inked(image.copy(0, top, image.width(), bottom - top)); }; // The short second line: left, middle, then right. const QRect left = lineInk(12 + 48, 12 + 96); QVERIFY(left.left() < 30); style.alignment = TextAlignment::center; const QRect centre = lineInk(12 + 48, 12 + 96); QVERIFY2(std::abs(centre.center().x() - 200) <= 3, qPrintable(QString::number(centre.center().x()))); style.alignment = TextAlignment::right; const QRect right = lineInk(12 + 48, 12 + 96); QVERIFY(right.right() > 400 - 12 - 12 && right.right() < 400 - 12); } void TextLayoutTests::baselinesSitAtTheBottomOfFixedLines() { // A tall line: letters sit low, the space above. LayerTextStyle style = styled(QStringLiteral("H"), 20); style.leading = 300; const QImage image = EditorSession::textImage(style); QCOMPARE(image.height(), 324); const QRect ink = inked(image); const QFontMetricsF metrics(TextLayout::font(style)); const double baseline = 12 + 300 - metrics.descent(); QVERIFY2(std::abs(ink.bottom() + 1 - baseline) <= 1.5 && ink.top() > 260, qPrintable(QString("%1 %2 %3").arg(ink.top()).arg(ink.bottom()).arg(baseline))); // A short line: letters reach into the one before. style.content = QStringLiteral("H\nH"); style.leading = 10; const QImage tight = EditorSession::textImage(style); QCOMPARE(tight.height(), 44); QVERIFY(inked(tight).top() < 12); } void TextLayoutTests::imagesKeepSwiftsLimits() { const auto refused = [](const LayerTextStyle &style) -> std::optional { try { EditorSession::textImage(style); } catch (const ProjectError &error) { return error.kind; } return std::nullopt; }; QCOMPARE(refused(styled(QStringLiteral("Text"), 0)), std::optional(ProjectError::Kind::invalid)); // A side past 30,000, then the area past 100 megapixels. const LayerTextStyle wide = styled(QStringLiteral("M").repeated(20), 2000); const QSizeF wideSize = EditorSession::textBoxSize(wide); QVERIFY(wideSize.width() > 30'000 && wideSize.width() < 40'000 && wideSize.width() * wideSize.height() < 100'000'000); QCOMPARE(refused(wide), std::optional(ProjectError::Kind::tooLarge)); const LayerTextStyle tall = styled(QStringLiteral("a\n").repeated(13), 2000); const QSizeF tallSize = EditorSession::textBoxSize(tall); QVERIFY(tallSize.height() > 30'000 && tallSize.height() < 40'000 && tallSize.width() * tallSize.height() < 100'000'000); QCOMPARE(refused(tall), std::optional(ProjectError::Kind::tooLarge)); const LayerTextStyle large = styled(QStringLiteral("M").repeated(14) + QStringLiteral("\nM"), 2000); const QSizeF largeSize = EditorSession::textBoxSize(large); QVERIFY(largeSize.width() < 30'000 && largeSize.width() * largeSize.height() > 100'000'000); QCOMPARE(refused(large), std::optional(ProjectError::Kind::tooLarge)); QVERIFY(!refused(styled(QStringLiteral("M").repeated(10), 2000))); // A fractional box rounds up to whole pixels. LayerTextStyle fractional = styled(QStringLiteral("Box"), 12); fractional.boxSize = QSizeF(100.2, 50.5); QCOMPARE(EditorSession::textImage(fractional).size(), QSize(101, 51)); } void TextLayoutTests::aBoxUnderItsPaddingHoldsAPixel() { // Both boxes leave a one-pixel container, as Swift's max(1, …). LayerTextStyle style = styled(QStringLiteral("I"), 10); style.leading = 1; style.alignment = TextAlignment::right; style.boxSize = QSizeF(16, 16); const QImage small = EditorSession::textImage(style); style.boxSize = QSizeF(25, 25); const QImage exact = EditorSession::textImage(style).copy(0, 0, 16, 16); QVERIFY(inked(small).isValid()); QCOMPARE(small, exact); } void TextLayoutTests::glyphsTakeTheStyleColour() { LayerTextStyle style = styled(QStringLiteral("H"), 72); style.red = 0.2; style.green = 0.4; style.blue = 0.8; const QImage image = EditorSession::textImage(style); // A solid pixel of the stem shows the colour itself. int solid = 0; QCOMPARE(image.format(), QImage::Format_RGBA8888_Premultiplied); for (int y = 0; y < image.height(); ++y) { for (int x = 0; x < image.width(); ++x) { const uchar *at = image.constScanLine(y) + x * 4; if (at[3] == 255) { ++solid; QCOMPARE((std::vector{at[0], at[1], at[2]}), (std::vector{51, 102, 204})); } } } QVERIFY(solid > 100); } void TextLayoutTests::paragraphsBreakWhereCocoaBreaksThem() { const auto height = [](const QString &text) { return EditorSession::textBoxSize(styled(text)).height(); }; const double one = height(QStringLiteral("A")), two = height(QStringLiteral("A\nB")); QCOMPARE(two, 197.0); // CR, CRLF, U+2029, and Qt's own U+2028, each break. for (const QString &text : {QStringLiteral("A\rB"), QStringLiteral("A\r\nB"), QStringLiteral("A") + QChar(0x2029) + QStringLiteral("B"), QStringLiteral("A") + QChar(0x2028) + QStringLiteral("B")}) QCOMPARE(height(text), two); // CRLF is one break; a final one adds no line. QCOMPARE(height(QStringLiteral("A\r\n")), one); QCOMPARE(height(QStringLiteral("A") + QChar(0x2029)), one); QCOMPARE(height(QStringLiteral("A\r\n\r\nB")), 284.0); } void TextLayoutTests::tabsStopEvery28Points() { const QSizeF room(100'000, 100'000); const double letter = TextLines(styled(QStringLiteral("B"), 24), room).usedSize().width(); const auto width = [&](const QString &text) { return TextLines(styled(text, 24), room).usedSize().width(); }; // NSParagraphStyle's twelve stops: every 28 pixels to 336. QVERIFY(std::abs(width(QStringLiteral("\tB")) - (28 + letter)) < 0.5); QVERIFY(std::abs(width(QStringLiteral("\t\tB")) - (56 + letter)) < 0.5); QVERIFY(std::abs(width(QStringLiteral("WWW\tB")) - (84 + letter)) < 0.5); QVERIFY(std::abs(width(QString(12, QLatin1Char('\t')) + QStringLiteral("B")) - (336 + letter)) < 0.5); // Past 336 they go on, where Cocoa wraps (known difference). const TextLines thirteen(styled(QString(13, QLatin1Char('\t')) + QStringLiteral("B"), 24), room); QVERIFY(std::abs(thirteen.usedSize().width() - (364 + letter)) < 0.5); QCOMPARE(thirteen.usedSize().height(), 28.8); } void TextLayoutTests::linesStopWhereTheBoxDoes() { // One line of room: "beta" and "more" fall past it. const TextLines lines(styled(QStringLiteral("alpha beta\nmore"), 24), QSizeF(90, 40)); QCOMPARE(lines.lineCount(), 1); QVERIFY(lines.overflows()); QCOMPARE(lines.lineOf(3), std::optional(0)); QCOMPARE(lines.lineOf(5), std::optional(0)); // Past the laid text: no line, no caret. for (const int position : {6, 8, 10, 11, 15}) QVERIFY2(!lines.lineOf(position) && !lines.caret(position), qPrintable(QString::number(position))); // The laid line still ends before its breaking space. QCOMPARE(lines.lineEnd(0), 5); // TextKit's line after a final newline holds the caret alone. const TextLines ending(styled(QStringLiteral("A\n"), 24), QSizeF(90, 40)); QCOMPARE(ending.lineCount(), 2); QVERIFY(!ending.overflows()); QCOMPARE(ending.usedSize().height(), 28.8); QCOMPARE(ending.lineOf(2), std::optional(1)); QCOMPARE(ending.caret(2).value().y(), 28.8); // So does a final U+2028; one inside makes a line. const QString separator(QChar::LineSeparator); const TextLines broken(styled(QStringLiteral("A") + separator, 24), QSizeF(90, 40)); QCOMPARE(broken.lineCount(), 2); QCOMPARE(broken.usedSize().height(), 28.8); QCOMPARE(broken.lineOf(2), std::optional(1)); QCOMPARE(broken.lineEnd(0), 1); QCOMPARE(TextLines(styled(QStringLiteral("A") + separator + QStringLiteral("\nB"), 24), QSizeF(90, 400)).usedSize().height(), 3 * 28.8); for (const QString &last : {QStringLiteral("\r"), QString(QChar::ParagraphSeparator)}) { const TextLines closed(styled(QStringLiteral("A") + last, 24), QSizeF(90, 40)); QCOMPARE(closed.lineCount(), 2); QCOMPARE(closed.usedSize().height(), 28.8); } // Only the text's last break has a caret's line. QVERIFY(TextLines(styled(QStringLiteral("A") + separator + QStringLiteral("\n"), 24), QSizeF(90, 40)).overflows()); const TextLines cut(styled(QStringLiteral("alpha beta") + separator, 24), QSizeF(90, 40)); QVERIFY(cut.overflows()); QCOMPARE(cut.lineCount(), 1); // Cut short, the text never reaches it. QCOMPARE(TextLines(styled(QStringLiteral("alpha beta\n"), 24), QSizeF(90, 40)).usedSize().height(), 28.8); } void TextLayoutTests::aSelectionFollowsItsRunsToTheLinesEdge() { const TextLines lines(styled(QStringLiteral("abc אבג xyz\nnext"), 24), QSizeF(400, 100)); const auto painted = [&](TextRange selection) { QImage image(400, 100, QImage::Format_ARGB32_Premultiplied); image.fill(Qt::white); QPainter painter(&image); painter.setPen(Qt::NoPen); lines.draw(painter, QPointF(0, 0), selection, {}, Qt::red); return image; }; // The first five letters: "abc " and aleph, not bet. const QImage five = painted({0, 5}); const int boundary = int(lines.caret(5).value().x()), middle = 14; QCOMPARE(five.pixelColor(boundary + 3, middle), QColor(Qt::red)); QCOMPARE(five.pixelColor(boundary - 3, middle), QColor(Qt::white)); QCOMPARE(five.pixelColor(5, middle), QColor(Qt::red)); // A tall line highlights top to bottom, past its letters. LayerTextStyle tall = styled(QStringLiteral("abc"), 24); tall.leading = 100; QImage high(400, 100, QImage::Format_ARGB32_Premultiplied); high.fill(Qt::white); QPainter onHigh(&high); onHigh.setPen(Qt::NoPen); TextLines(tall, QSizeF(400, 100)).draw(onHigh, QPointF(0, 0), {0, 3}, {}, Qt::red); onHigh.end(); QCOMPARE(high.pixelColor(5, 2), QColor(Qt::red)); QCOMPARE(high.pixelColor(5, 97), QColor(Qt::red)); // Past a line's end it runs to the edge. const QImage across = painted({1, 14}); QCOMPARE(across.pixelColor(395, middle), QColor(Qt::red)); QCOMPARE(across.pixelColor(1, 28 + middle), QColor(Qt::red)); QCOMPARE(across.pixelColor(int(lines.caret(14).value().x()) + 4, 28 + middle), QColor(Qt::white)); QCOMPARE(across.pixelColor(1, middle), QColor(Qt::white)); // A lone newline does too; a wrap's start does not. QCOMPARE(painted({11, 12}).pixelColor(395, middle), QColor(Qt::red)); LayerTextStyle wrapped = styled(QStringLiteral("alpha beta"), 24); const TextLines two(wrapped, QSizeF(90, 100)); QImage second(90, 100, QImage::Format_ARGB32_Premultiplied); second.fill(Qt::white); QPainter onSecond(&second); onSecond.setPen(Qt::NoPen); two.draw(onSecond, QPointF(0, 0), {6, 10}, {}, Qt::red); onSecond.end(); QCOMPARE(second.pixelColor(85, middle), QColor(Qt::white)); QCOMPARE(second.pixelColor(2, 28 + middle), QColor(Qt::red)); QCOMPARE(across.pixelColor(395, 28 + middle), QColor(Qt::white)); // Margins fill where a line reads from, or to. const auto aligned = [](const QString &content, TextAlignment alignment) { LayerTextStyle style = styled(content, 24); style.alignment = alignment; return style; }; const auto margins = [&](const LayerTextStyle &style, TextRange selection) { QImage image(400, 100, QImage::Format_ARGB32_Premultiplied); image.fill(Qt::white); QPainter painter(&image); painter.setPen(Qt::NoPen); TextLines(style, QSizeF(400, 100)).draw(painter, QPointF(0, 0), selection, {}, Qt::red); return image; }; const LayerTextStyle centred = aligned(QStringLiteral("ab\ncd"), TextAlignment::center); const TextLines centredLines(centred, QSizeF(400, 100)); QCOMPARE(margins(centred, {1, 5}).pixelColor(5, 28 + middle), QColor(Qt::red)); QCOMPARE(margins(centred, {1, 5}).pixelColor(5, middle), QColor(Qt::white)); QCOMPARE(margins(centred, {0, 3}).pixelColor(5, 28 + middle), QColor(Qt::white)); const int d = int((centredLines.caret(4).value().x() + centredLines.caret(5).value().x()) / 2); QCOMPARE(margins(centred, {1, 4}).pixelColor(d, 28 + middle), QColor(Qt::white)); QCOMPARE(margins(aligned(QStringLiteral("אב\nגד"), TextAlignment::left), {1, 5}).pixelColor(395, 28 + middle), QColor(Qt::red)); const QImage right = margins(aligned(QStringLiteral("אב\nגד"), TextAlignment::right), {1, 5}); QCOMPARE(right.pixelColor(5, middle), QColor(Qt::red)); QCOMPARE(right.pixelColor(395, middle), QColor(Qt::white)); } QTEST_MAIN(TextLayoutTests) #include "TextLayoutTests.moc"