Faces for the selected letters, project format version 11 (13.2)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 00:05:20 -04:00
1 parent 802ae82f72
commit dcedfea53f
29 files changed
+869 -139

No files matched your search

+1 -1
View File
@@ -238,7 +238,7 @@ void FolderOpacityTests::aDimmedFolderSavesAndReopens()
const QString path = root.filePath("Dimmed.comp");
ProjectStore::save(session.projectSnapshot().value(), path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QCOMPARE(loaded.manifest.version, qint64(10));
QCOMPARE(loaded.manifest.version, qint64(11));
for (const ProjectLayerRecord &record : loaded.manifest.layers) {
if (record.isGroup == true)
QCOMPARE(record.opacity, std::optional(0.5));
+1 -1
View File
@@ -217,7 +217,7 @@ void GroupSessionTests::groupsRoundTripAndSurviveImageAndCanvasResize()
session.addBlankLayer();
const QUuid child = session.activeLayerID().value();
const ProjectSnapshot snapshot = session.projectSnapshot().value();
QCOMPARE(snapshot.manifest.version, qint64(10));
QCOMPARE(snapshot.manifest.version, qint64(11));
const auto record = [](const ProjectSnapshot &from, QUuid id) {
for (const ProjectLayerRecord &layer : from.manifest.layers) {
if (layer.id == id)
+3 -3
View File
@@ -259,10 +259,10 @@ void GroupTests::projectErrorsCarryTheirMessages()
QCOMPARE(invalid.version, std::nullopt);
QCOMPARE(QString(invalid.what()), QString("This is not a valid OmaPhoto project, or its metadata is damaged."));
const ProjectError version = ProjectError::unsupportedVersion(11);
const ProjectError version = ProjectError::unsupportedVersion(12);
QCOMPARE(version.kind, ProjectError::Kind::version);
QCOMPARE(version.version, std::optional<int>(11));
QCOMPARE(QString(version.what()), QString("This project uses format version 11. This app supports versions 1–10."));
QCOMPARE(version.version, std::optional<int>(12));
QCOMPARE(QString(version.what()), QString("This project uses format version 12. This app supports versions 1–11."));
QVERIFY_THROWS_EXCEPTION(std::logic_error, ProjectError(ProjectError::Kind::version));
const ProjectError missingImage(ProjectError::Kind::missingImage);
+1 -1
View File
@@ -139,7 +139,7 @@ void GuideTests::projectRoundTripAndLegacyRejection()
session.addGuide(first);
session.addGuide(second);
ProjectSnapshot snapshot = session.projectSnapshot().value();
QCOMPARE(snapshot.manifest.version, qint64(10));
QCOMPARE(snapshot.manifest.version, qint64(11));
QCOMPARE(snapshot.manifest.guides.value(), (std::vector<CanvasGuide>{first, second}));
// Swift's keys: an array of id, axis and position.
const QByteArray json = snapshot.manifest.encoded();
+1 -1
View File
@@ -119,7 +119,7 @@ void LayerAppearanceTests::appearancePersistsThroughSaveResizeAndTransparentExpo
const QString path = folder.filePath("Appearance.comp");
ProjectStore::save(snapshot, path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QCOMPARE(loaded.manifest.version, qint64(10));
QCOMPARE(loaded.manifest.version, qint64(11));
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 8, .height = 8, .resolution = 72});
const ProjectSnapshot canvas = CanvasResizer::resize(resized, {.width = 12, .height = 12});
const auto record = std::find_if(canvas.manifest.layers.begin(), canvas.manifest.layers.end(),
+2 -2
View File
@@ -160,7 +160,7 @@ void LayerMaskSessionTests::projectAndPNGPreserveCoverageAndDisabledState()
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskEnabled = enabled; });
ProjectStore::save(session->projectSnapshot().value(), path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QCOMPARE(loaded.manifest.version, qint64(10));
QCOMPARE(loaded.manifest.version, qint64(11));
session->installProject(loaded, path);
QCOMPARE(session->activeLayer().value().mask.value().isEnabled, enabled);
const QImage image = QImage::fromData(ImageExporter::pngData(loaded), "PNG");
@@ -240,7 +240,7 @@ void LayerMaskSessionTests::folderMasksSaveResizeAndNeedTheNewFormat()
const ProjectSnapshot snapshot = session->projectSnapshot().value();
ProjectStore::save(snapshot, path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QVERIFY(loaded.manifest.version == 10 && loaded.masks.count(folder) == 1);
QVERIFY(loaded.manifest.version == 11 && loaded.masks.count(folder) == 1);
QCOMPARE(alphas(ImageExporter::render(loaded).image), (QList<int>{255, 0, 128, 255}));
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 4, .height = 4, .resolution = 72, .sampling = LayerSampling::nearest});
QCOMPARE(alphas(ImageExporter::render(resized).image),
+6 -2
View File
@@ -313,7 +313,7 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
for (int version = 1; version <= 9; ++version)
QTest::addRow("plain at version %d", version) << "" << version << fine;
QTest::newRow("version 0") << "" << 0 << std::optional(ProjectError::Kind::version);
QTest::newRow("version 11") << "" << 11 << std::optional(ProjectError::Kind::version);
QTest::newRow("version 12") << "" << 12 << std::optional(ProjectError::Kind::version);
QTest::newRow("another format") << "format" << 7 << invalid;
QTest::newRow("another colour space") << "colorSpace" << 7 << invalid;
QTest::newRow("resolution 0.5") << "resolution=0.5" << 7 << invalid;
@@ -369,6 +369,8 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
QTest::newRow("letters in colours before version 10") << "textRuns" << 9 << invalid;
QTest::newRow("letters in colours at version 10") << "textRuns" << 10 << fine;
QTest::newRow("one-colour text before version 10") << "text" << 9 << fine;
QTest::newRow("letters in faces before version 11") << "textFonts" << 10 << invalid;
QTest::newRow("letters in faces at version 11") << "textFonts" << 11 << fine;
}
void ProjectManifestTests::validationFollowsTheFormatsVersions()
@@ -458,11 +460,13 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions()
image.imageFile = "image.png";
else if (change == "imageLower")
image.imageFile = image.id.toString(QUuid::WithoutBraces) + ".png";
else if (change == "text" || change == "textRuns") {
else if (change == "text" || change == "textRuns" || change == "textFonts") {
LayerTextStyle style;
style.content = QStringLiteral("Ab");
if (change == "textRuns")
style.colorRuns = std::vector{LayerTextColorRun{1, 1, 1, 0, 0}};
if (change == "textFonts")
style.fontRuns = std::vector{LayerTextFontRun{1, 1, QStringLiteral("DejaVuSansMono")}};
image.text = style;
} else if (change == "active")
manifest.activeLayerID = QUuid::createUuid();
+1 -1
View File
@@ -122,7 +122,7 @@ void ProjectSessionTests::aSnapshotGivesBackWhatWasInstalled()
const ProjectSnapshot again = session.projectSnapshot().value();
// The same manifest to the byte; pixels by identity.
QCOMPARE(again.manifest.encoded(), snapshot.manifest.encoded());
QCOMPARE(again.manifest.version, qint64(10));
QCOMPARE(again.manifest.version, qint64(11));
QCOMPARE(int(again.images.size()), 2);
QCOMPARE(int(again.masks.size()), 2);
for (const auto &[id, image] : snapshot.images)
+12 -12
View File
@@ -53,10 +53,10 @@ private slots:
void ProjectTests::theCurrentFormatVersionIsOneTheReaderAccepts()
{
QVERIFY(ProjectManifest::supports(ProjectManifest::current));
QVERIFY((ProjectManifest::supported == std::pair<qint64, qint64>(1, 10)));
QVERIFY((ProjectManifest::supported == std::pair<qint64, qint64>(1, 11)));
QCOMPARE(ProjectManifest{}.version, ProjectManifest::current);
QVERIFY(!ProjectManifest::supports(0) && !ProjectManifest::supports(ProjectManifest::current + 1));
QCOMPARE(QString(ProjectError::unsupportedVersion(11).what()), QString("This project uses format version 11. This app supports versions 1–10."));
QCOMPARE(QString(ProjectError::unsupportedVersion(12).what()), QString("This project uses format version 12. This app supports versions 1–11."));
}
void ProjectTests::projectRoundTripSurvivesPackageMove()
@@ -201,14 +201,14 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
ProjectStore::save(snapshot, path);
const QByteArray good = contents(metadata);
// Each edit below replaces this text; it must be there.
QVERIFY(good.contains("\"version\": 10"));
QVERIFY(good.contains("\"version\": 11"));
const auto loading = [&](const QByteArray &manifest) {
overwrite(metadata, manifest);
return projectError([&] { ProjectStore::load(path); });
};
// A version out of range is named whatever else fails.
const QByteArray future = R"({"format": "com.compositor.project", "version": 42, "layers": "from the future"})";
for (const qint64 strange : {qint64(42), qint64(11), qint64(0)}) {
for (const qint64 strange : {qint64(42), qint64(12), qint64(0)}) {
try {
overwrite(metadata, QByteArray(future).replace("42", QByteArray::number(strange)));
ProjectStore::load(path);
@@ -223,12 +223,12 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
QCOMPARE(loading(QByteArray(future).replace("42", "1")), std::optional(ProjectError::Kind::invalid));
// Another format is invalid whatever version it claims.
QCOMPARE(loading(QByteArray(future).replace("compositor", "example")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 0")), std::optional(ProjectError::Kind::version));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 0")), std::optional(ProjectError::Kind::version));
// Swift's Int holds 64 bits, each one: no double between.
const qint64 most = std::numeric_limits<qint64>::max(), least = std::numeric_limits<qint64>::min();
for (const qint64 wild : {qint64(2147483648), qint64(-2147483648), qint64(-1), qint64(9007199254740993), most, least}) {
try {
overwrite(metadata, QByteArray(good).replace("\"version\": 10", "\"version\": " + QByteArray::number(wild)));
overwrite(metadata, QByteArray(good).replace("\"version\": 11", "\"version\": " + QByteArray::number(wild)));
ProjectStore::load(path);
QFAIL("a wild version opened");
} catch (const ProjectError &error) {
@@ -238,19 +238,19 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
}
}
QCOMPARE(loading(QByteArray(good).replace("\"width\": 100", "\"width\": 3000000000")), std::optional(ProjectError::Kind::tooLarge));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
// Declared: Qt rounds a token just past Int64's minimum.
try {
overwrite(metadata, QByteArray(good).replace("\"version\": 10", "\"version\": -9223372036854775809"));
overwrite(metadata, QByteArray(good).replace("\"version\": 11", "\"version\": -9223372036854775809"));
ProjectStore::load(path);
QFAIL("a version below every integer opened");
} catch (const ProjectError &error) {
QCOMPARE(error.version, std::optional(least));
}
QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("com.compositor.project", "com.example.project")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading("not json"), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading("[1, 2]"), std::optional(ProjectError::Kind::invalid));
+2
View File
@@ -173,6 +173,8 @@ compositor_test(TextColorRunsTests)
compositor_test(TextColorModelTests)
compositor_test(TextColorEditorTests)
compositor_test(TextEditingGatesTests)
compositor_test(TextFontRunsTests)
compositor_test(TextFontControlsTests)
compositor_test(DitherTests)
compositor_test(DitherSheetTests)
compositor_test(DitherKernelTests)
+4
View File
@@ -44,6 +44,10 @@ void TextColorModelTests::colorAppliesToSelectionAndFollowsEdits()
{
LayerTextStyle style;
style.content = QStringLiteral("Hello world");
// The whole text takes every channel of its colour.
style.setColor(PaletteColor{0.25, 0.5, 0.75}, {0, 0});
QVERIFY(style.red == 0.25 && style.green == 0.5 && style.blue == 0.75 && !style.colorRuns);
style.setColor(PaletteColor::black(), {0, 11});
style.setColor(red, {6, 5});
QCOMPARE(style.colorRuns.value(), (std::vector{run(6, 5, red)}));
QVERIFY(style.color(5) == PaletteColor::black() && style.color(6) == red);
+1 -1
View File
@@ -135,7 +135,7 @@ void TextColorRunsTests::selectedColorPaintsOnlyThoseLettersAndSurvivesReopening
const auto pixels = redAndDark(session->activeLayer().value().asset.value().image());
QVERIFY2(pixels.first > 50 && pixels.second > 50, qPrintable(QStringLiteral("%1 %2").arg(pixels.first).arg(pixels.second)));
const ProjectSnapshot snapshot = session->projectSnapshot().value();
QCOMPARE(snapshot.manifest.version, qint64(10));
QCOMPARE(snapshot.manifest.version, qint64(11));
QTemporaryDir root;
const QString path = root.filePath(QStringLiteral("TextColors.comp"));
ProjectStore::save(snapshot, path);
+193
View File
@@ -0,0 +1,193 @@
#include "InlineTextDrawFixtures.h"
#include "UI/TypeControls.h"
#include <QAbstractItemView>
#include <QFontMetricsF>
#include "Rendering/TextLayout.h"
// Swift 1.3.4's font menu: the selection's face, or (Multiple).
namespace {
const QString sans = QStringLiteral("DejaVuSans");
const QString mono = QStringLiteral("DejaVuSansMono");
// Typed text with a menu beside the canvas.
struct Typed : DrawnText {
TypeControls *const bar = new TypeControls(session, &window);
TypeFontPicker &menu() const { return *bar->findChild<TypeFontPicker *>(QStringLiteral("typeFont")); }
Typed()
{
bar->setGeometry(0, 300, 400, 40);
bar->show();
session.changeTextStyle([](LayerTextStyle &style) { style.fontName = sans; });
canvas->setFocus();
beginTextAt(session, QPointF(20, 30));
QTest::keyClicks(canvas, QStringLiteral("Hello"));
}
const LayerTextStyle &style() const { return session.textDraft().value().style; }
// The menu chooses a face, as a click does.
void choose(const QString &face)
{
if (menu().findText(face) < 0) {
menu().showPopup();
menu().hidePopup();
}
emit menu().activated(menu().findText(face));
}
};
}
class TextFontControlsTests : public QObject {
Q_OBJECT
private slots:
void theMenuShowsTheCaretsFaceOrMultiple();
void aChosenFaceTakesTheSelectionTheMenuKept();
void typingAndUndoKeepTheFaces();
void theLoadedMenuKeepsMultipleOnTop();
void deletingToOneFaceKeepsItsLetters();
void theEditorPlacesTheCaretByTheFaces();
};
void TextFontControlsTests::theMenuShowsTheCaretsFaceOrMultiple()
{
Typed typed;
typed.session.changeTextStyle([](LayerTextStyle &style) { style.setFont(mono, {0, 2}); });
// The caret shows the letter before it.
QTest::keyClick(typed.canvas, Qt::Key_Home);
QTest::keyClick(typed.canvas, Qt::Key_Right);
QCOMPARE(typed.menu().currentText(), mono);
QTest::keyClick(typed.canvas, Qt::Key_Right);
QCOMPARE(typed.menu().currentText(), mono);
QTest::keyClick(typed.canvas, Qt::Key_Right);
QCOMPARE(typed.menu().currentText(), sans);
QTest::keyClick(typed.canvas, Qt::Key_End);
QCOMPARE(typed.menu().currentText(), sans);
// At the start, the first letter.
QTest::keyClick(typed.canvas, Qt::Key_Home);
QCOMPARE(typed.menu().currentText(), mono);
// Across both faces: (Multiple), on top, no font.
QTest::keyClick(typed.canvas, Qt::Key_Right);
QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
QCOMPARE(typed.menu().currentIndex(), 0);
QCOMPARE(typed.menu().currentText(), QStringLiteral("(Multiple)"));
QVERIFY(typed.menu().isMultiple(0));
// Still mixed: the item stays one.
QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
QCOMPARE(typed.menu().currentText(), QStringLiteral("(Multiple)"));
QVERIFY(!typed.menu().isMultiple(1));
// One face again: the item goes.
QTest::keyClick(typed.canvas, Qt::Key_End);
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
QCOMPARE(typed.menu().currentText(), sans);
QVERIFY(!typed.menu().isMultiple(0) && typed.menu().findText(QStringLiteral("(Multiple)")) < 0);
}
void TextFontControlsTests::aChosenFaceTakesTheSelectionTheMenuKept()
{
Typed typed;
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
// The menu takes the keys; the letters stay selected.
typed.menu().setFocus(Qt::MouseFocusReason);
QTRY_VERIFY(!typed.canvas->hasFocus());
QCOMPARE(typed.session.textDraft().value().selection, (TextSpan{3, 2}));
QVERIFY(typed.editor().anchor() == 5 && typed.editor().caretPosition() == 3);
typed.choose(mono);
QCOMPARE(typed.style().fontRuns.value(), (std::vector<LayerTextFontRun>{{3, 2, mono}}));
QCOMPARE(typed.style().fontName, sans);
// The face shown, or (Multiple), changes nothing.
QSignalSpy changed(&typed.session, &EditorSession::changed);
typed.choose(mono);
QCOMPARE(int(changed.count()), 0);
typed.canvas->setFocus();
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
QCOMPARE(typed.menu().currentText(), QStringLiteral("(Multiple)"));
const int heard = int(changed.count());
emit typed.menu().activated(0);
QCOMPARE(int(changed.count()), heard);
// From (Multiple), any face takes the whole selection.
typed.choose(sans);
QVERIFY(!typed.style().fontRuns && typed.style().fontName == sans);
// A caret alone sets the whole text's face.
QTest::keyClick(typed.canvas, Qt::Key_End);
typed.choose(mono);
QVERIFY(!typed.style().fontRuns && typed.style().fontName == mono);
}
void TextFontControlsTests::typingAndUndoKeepTheFaces()
{
Typed typed;
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
typed.choose(mono);
// Typed after mono, mono; after sans, sans.
QTest::keyClick(typed.canvas, Qt::Key_End);
QTest::keyClicks(typed.canvas, QStringLiteral("!"));
QCOMPARE(typed.style().fontRuns.value(), (std::vector<LayerTextFontRun>{{3, 3, mono}}));
QTest::keyClick(typed.canvas, Qt::Key_Home);
QTest::keyClick(typed.canvas, Qt::Key_Right);
QTest::keyClicks(typed.canvas, QStringLiteral("x"));
QCOMPARE(typed.style().content, QStringLiteral("Hxello!"));
QCOMPARE(typed.style().fontRuns.value(), (std::vector<LayerTextFontRun>{{4, 3, mono}}));
// Undo takes the typing back, the faces with it.
QTest::keyClick(typed.canvas, Qt::Key_Z, Qt::ControlModifier);
QCOMPARE(typed.style().content, QStringLiteral("Hello!"));
QCOMPARE(typed.style().fontRuns.value(), (std::vector<LayerTextFontRun>{{3, 3, mono}}));
// Applied, the layer keeps them.
QVERIFY(typed.session.finishText());
QCOMPARE(typed.session.activeLayer().value().liveText().value().style.fontRuns.value(), (std::vector<LayerTextFontRun>{{3, 3, mono}}));
}
void TextFontControlsTests::theLoadedMenuKeepsMultipleOnTop()
{
Typed typed;
typed.session.changeTextStyle([](LayerTextStyle &style) { style.setFont(mono, {0, 2}); });
QTest::keyClick(typed.canvas, Qt::Key_A, Qt::ControlModifier);
QCOMPARE(typed.menu().currentText(), QStringLiteral("(Multiple)"));
typed.menu().showPopup();
QVERIFY(typed.menu().isMultiple(0) && typed.menu().currentIndex() == 0);
QVERIFY(typed.menu().findText(mono) > 0 && typed.menu().findText(sans) > 0);
typed.menu().hidePopup();
// A single face after loading drops the item again.
QTest::keyClick(typed.canvas, Qt::Key_End);
QTRY_COMPARE(typed.menu().currentText(), sans);
QVERIFY(!typed.menu().isMultiple(0));
}
// A fix beyond Swift, whose draft drops the folded face.
void TextFontControlsTests::deletingToOneFaceKeepsItsLetters()
{
Typed typed;
QTest::keyClick(typed.canvas, Qt::Key_Home);
QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
typed.choose(mono);
QTest::keyClick(typed.canvas, Qt::Key_End);
for (int letter = 0; letter < 3; ++letter)
QTest::keyClick(typed.canvas, Qt::Key_Backspace);
QCOMPARE(typed.style().content, QStringLiteral("He"));
QVERIFY(!typed.style().fontRuns && typed.style().fontName == mono);
QVERIFY(typed.session.finishText());
QCOMPARE(typed.session.activeLayer().value().liveText().value().style.fontName, mono);
}
// A click lands by the faces the letters show.
void TextFontControlsTests::theEditorPlacesTheCaretByTheFaces()
{
Typed typed;
QTest::keyClick(typed.canvas, Qt::Key_A, Qt::ControlModifier);
QTest::keyClicks(typed.canvas, QStringLiteral("iiii"));
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
typed.choose(mono);
const double narrow = QFontMetricsF(TextLayout::font(typed.style(), sans)).horizontalAdvance(QLatin1Char('i'));
const double wide = QFontMetricsF(TextLayout::font(typed.style(), mono)).horizontalAdvance(QLatin1Char('i'));
QVERIFY(wide > 2 * narrow);
// A fifth into the last letter: before it, in mono.
const double x = LayerTextStyle::padding + 2 * narrow + wide + wide / 5;
const QPointF at = typed.editor().textTransform().map(QPointF(x, LayerTextStyle::padding + typed.style().lineHeight() / 2));
typed.click(at);
QCOMPARE(typed.editor().caretPosition(), 3);
}
QTEST_MAIN(TextFontControlsTests)
#include "TextFontControlsTests.moc"
+290
View File
@@ -0,0 +1,290 @@
#include "Document/EditorSession.h"
#include "IO/ProjectStore.h"
#include "Rendering/TextLayout.h"
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QFontInfo>
#include <QFontMetricsF>
#include <limits>
#include <QTemporaryDir>
#include <QtTest>
// Swift 1.3.4: a font change applies to the selected letters.
namespace {
const QString sans = QStringLiteral("DejaVuSans");
const QString mono = QStringLiteral("DejaVuSansMono");
std::unique_ptr<EditorSession> makeSession()
{
auto session = std::make_unique<EditorSession>();
session->createDocument(800, 600, true);
session->selectTool(NavigationTool::type);
return session;
}
LayerTextStyle hello()
{
LayerTextStyle style;
style.content = QStringLiteral("Hello");
style.fontName = sans;
return style;
}
// A saved text layer whose first two letters are mono.
struct SavedFonts {
QTemporaryDir root;
QString path = root.filePath(QStringLiteral("Fonts.comp"));
SavedFonts()
{
const auto session = makeSession();
session->beginText(QPointF(30, 40));
TextDraft draft = session->textDraft().value();
draft.style.content = QStringLiteral("Hello");
draft.style.fontName = sans;
draft.selection = {0, 2};
session->setTextDraft(draft);
session->changeTextStyle([](LayerTextStyle &style) { style.setFont(mono, {0, 2}); });
if (!session->finishText())
throw std::runtime_error("the text was refused");
ProjectStore::save(session->projectSnapshot().value(), path);
}
// The manifest with its text's runs and version rewritten.
std::optional<ProjectError::Kind> load(qint64 version, const QJsonValue &fontRuns) const
{
QFile file(path + QStringLiteral("/manifest.json"));
if (!file.open(QIODevice::ReadOnly))
throw std::runtime_error("no manifest");
QJsonObject manifest = QJsonDocument::fromJson(file.readAll()).object();
file.close();
manifest["version"] = version;
QJsonArray layers = manifest["layers"].toArray();
const qsizetype index = layers.size() - 1;
QJsonObject layer = layers[index].toObject();
if (!layer.contains("text"))
throw std::runtime_error("the top layer holds no text");
QJsonObject text = layer["text"].toObject();
text["fontRuns"] = fontRuns;
layer["text"] = text;
layers[index] = layer;
manifest["layers"] = layers;
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate) || file.write(QJsonDocument(manifest).toJson()) < 0)
throw std::runtime_error("the manifest could not be written");
file.close();
try {
ProjectStore::load(path);
return std::nullopt;
} catch (const ProjectError &error) {
return error.kind;
}
}
};
QJsonObject run(qint64 location, qint64 length, const QString &face)
{
return {{"location", location}, {"length", length}, {"fontName", face}};
}
}
class TextFontRunsTests : public QObject {
Q_OBJECT
private slots:
void fontAppliesToTheSelectionOnly();
void selectedFontSurvivesReopening();
void runsNeedVersionElevenAndAValidFace();
void eachRunLaysOutInItsFace();
void newTextStartsInOneFace();
};
// Swift's test, with faces of different widths kept.
void TextFontRunsTests::fontAppliesToTheSelectionOnly()
{
LayerTextStyle style = hello();
style.setFont(mono, {0, 2});
QCOMPARE(style.fontName, sans);
QCOMPARE(style.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 2, mono}}));
QVERIFY(style.fontNameAt(0) == mono && style.fontNameAt(2) == sans);
const double mixed = EditorSession::textBoxSize(style).width();
style.setFont(mono, {0, 0});
QVERIFY(!style.fontRuns && style.fontName == mono);
QVERIFY(EditorSession::textBoxSize(style).width() > mixed);
style.setFont(sans, {1, 3});
style.replaceCharacters({5, 0}, 1);
style.content += QStringLiteral("!");
QVERIFY(style.isValid() && style.fontNameAt(5) == mono);
QCOMPARE(style.uniformFontName({0, 2}), std::optional<QString>());
QCOMPARE(style.uniformFontName({1, 3}), std::optional(sans));
QCOMPARE(style.uniformFontName({4, 2}), std::optional(mono));
QCOMPARE(style.uniformFontName({2, 0}), std::optional<QString>());
style.setFont(mono, {0, 6});
QVERIFY(!style.fontRuns && style.fontName == mono);
style.setFont(sans, {0, 2});
QCOMPARE(style.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 2, sans}}));
// A name that is no face changes nothing.
style.setFont(QString(), {2, 1});
style.setFont(QStringLiteral("Bad\nName"), {2, 1});
QCOMPARE(style.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 2, sans}}));
// Same-face runs apart stay apart; a base gap mixes.
LayerTextStyle apart = hello();
apart.setFont(mono, {0, 1});
apart.setFont(mono, {3, 1});
QCOMPARE(apart.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 1, mono}, {3, 1, mono}}));
QCOMPARE(apart.uniformFontName({0, 4}), std::optional<QString>());
QCOMPARE(apart.uniformFontName({3, 1}), std::optional(mono));
// All letters in the run's face: the text's face.
LayerTextStyle kept = hello();
kept.setFont(mono, {0, 2});
kept.replaceCharacters({2, 3}, 0);
kept.content.truncate(2);
QVERIFY(!kept.fontRuns && kept.fontName == mono);
// Neighbouring runs in two faces stay two.
const QString bold = QStringLiteral("DejaVuSans-Bold");
LayerTextStyle three = hello();
three.setFont(mono, {0, 1});
three.setFont(bold, {1, 1});
QCOMPARE(three.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 1, mono}, {1, 1, bold}}));
QVERIFY(three.fontNameAt(0) == mono && three.fontNameAt(1) == bold && three.fontNameAt(2) == sans);
// A run starting at the span's end stays out.
QCOMPARE(three.uniformFontName({0, 1}), std::optional(mono));
QCOMPARE(three.uniformFontName({2, 3}), std::optional(sans));
// A span past the end stops at it.
three.setFont(mono, {3, 99});
QCOMPARE(three.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 1, mono}, {1, 1, bold}, {3, 2, mono}}));
QCOMPARE(three.uniformFontName({3, 99}), std::optional(mono));
// Inserted at the start, letters take the first letter's face.
LayerTextStyle first = hello();
first.setFont(mono, {0, 1});
first.replaceCharacters({0, 0}, 2);
first.content.prepend(QStringLiteral("xy"));
QCOMPARE(first.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 3, mono}}));
first.replaceCharacters({0, 1}, 1);
QCOMPARE(first.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 3, mono}}));
// Deleting a run's letters drops the run.
style.replaceCharacters({0, 2}, 0);
style.content.remove(0, 2);
QVERIFY(!style.fontRuns && style.fontName == mono);
}
void TextFontRunsTests::selectedFontSurvivesReopening()
{
const SavedFonts saved;
EditorSession reopened;
reopened.installProject(ProjectStore::load(saved.path), saved.path);
QCOMPARE(reopened.document().value().layers.back().liveText().value().style.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 2, mono}}));
QCOMPARE(reopened.document().value().layers.back().liveText().value().style.fontName, sans);
QCOMPARE(ProjectStore::load(saved.path).manifest.version, qint64(11));
}
void TextFontRunsTests::runsNeedVersionElevenAndAValidFace()
{
const SavedFonts saved;
QCOMPARE(saved.load(11, QJsonArray{run(0, 2, mono)}), std::optional<ProjectError::Kind>());
// Version 10 cannot hold faces.
QCOMPARE(saved.load(10, QJsonArray{run(0, 2, mono)}), std::optional(ProjectError::Kind::invalid));
// Overlapping, empty, past the end, nameless, newline, long, none.
for (const QJsonArray &runs : {QJsonArray{run(0, 2, mono), run(1, 2, mono)}, QJsonArray{run(0, 0, mono)}, QJsonArray{run(4, 2, mono)},
QJsonArray{run(0, 2, QString())}, QJsonArray{run(0, 2, QStringLiteral("A
B"))},
QJsonArray{run(0, 2, QString(201, u'x'))}, QJsonArray{}})
QCOMPARE(saved.load(11, runs), std::optional(ProjectError::Kind::invalid));
// Two hundred characters, a decomposed letter counted once, pass.
QString name;
for (int index = 0; index < 200; ++index)
name += QStringLiteral("é");
QCOMPARE(saved.load(11, QJsonArray{run(0, 2, name)}), std::optional<ProjectError::Kind>());
// A negative start, a fractional place or length.
QCOMPARE(saved.load(11, QJsonArray{run(-1, 2, mono)}), std::optional(ProjectError::Kind::invalid));
QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"location", 0.5}, {"length", 2}, {"fontName", mono}}}), std::optional(ProjectError::Kind::invalid));
QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"location", 0}, {"length", 2.5}, {"fontName", mono}}}), std::optional(ProjectError::Kind::invalid));
// Every newline character, and a run past Int's end.
for (const QChar newline : {QChar(u'\n'), QChar(u'\r'), QChar(u'\v'), QChar(u'\f'), QChar(0x85), QChar(QChar::LineSeparator), QChar(QChar::ParagraphSeparator)})
QCOMPARE(saved.load(11, QJsonArray{run(0, 2, QStringLiteral("A") + newline + QStringLiteral("B"))}), std::optional(ProjectError::Kind::invalid));
QCOMPARE(saved.load(11, QJsonArray{run(std::numeric_limits<qint64>::max(), 1, mono)}), std::optional(ProjectError::Kind::invalid));
// A run missing a key, or no array, fails.
QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"location", 0}, {"length", 2}}}), std::optional(ProjectError::Kind::invalid));
QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"length", 2}, {"fontName", mono}}}), std::optional(ProjectError::Kind::invalid));
QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"location", 0}, {"fontName", mono}}}), std::optional(ProjectError::Kind::invalid));
QCOMPARE(saved.load(11, QJsonValue(3)), std::optional(ProjectError::Kind::invalid));
}
void TextFontRunsTests::eachRunLaysOutInItsFace()
{
LayerTextStyle style = hello();
style.content = QStringLiteral("iiii\niiii");
const double plain = TextLines(style, {10'000, 10'000}).usedSize().width();
// A run on the second paragraph widens that line alone.
style.setFont(mono, {5, 4});
const double wider = TextLines(style, {10'000, 10'000}).usedSize().width();
QCOMPARE(wider, 4 * QFontMetricsF(TextLayout::font(style, mono)).horizontalAdvance(QLatin1Char('i')));
QVERIFY(wider > plain);
// A face named by PostScript keeps its style: bold.
const QString bold = QStringLiteral("DejaVuSans-Bold");
LayerTextStyle heavy = hello();
heavy.content = QStringLiteral("iiiii");
heavy.setFont(bold, {0, 4});
QCOMPARE(QFontInfo(TextLayout::font(heavy, bold)).styleName(), QStringLiteral("Bold"));
// An independent reference: DejaVu Sans Bold at 72 pixels.
QFont reference(QStringLiteral("DejaVu Sans"));
reference.setStyleName(QStringLiteral("Bold"));
reference.setPixelSize(72);
reference.setKerning(false);
reference.setHintingPreference(QFont::PreferNoHinting);
QFont regular = reference;
regular.setStyleName(QStringLiteral("Book"));
QCOMPARE(TextLines(heavy, {10'000, 10'000}).usedSize().width(),
4 * QFontMetricsF(reference).horizontalAdvance(QLatin1Char('i')) + QFontMetricsF(regular).horizontalAdvance(QLatin1Char('i')));
QVERIFY(QFontMetricsF(reference).horizontalAdvance(QLatin1Char('i')) > QFontMetricsF(regular).horizontalAdvance(QLatin1Char('i')));
// A run across a break covers both lines.
style.fontRuns = std::nullopt;
style.setFont(mono, {2, 5});
const TextLines across(style, {10'000, 10'000});
const double narrow = QFontMetricsF(TextLayout::font(style, sans)).horizontalAdvance(QLatin1Char('i'));
const double wide = QFontMetricsF(TextLayout::font(style, mono)).horizontalAdvance(QLatin1Char('i'));
QCOMPARE(across.caret(4).value().left(), 2 * narrow + 2 * wide);
QCOMPARE(across.caret(9).value().left(), 2 * wide + 2 * narrow);
// At another size and tracking, against fonts built here.
LayerTextStyle sized = hello();
sized.content = QStringLiteral("iiiii");
sized.fontSize = 40;
sized.tracking = 3;
sized.setFont(mono, {1, 2});
const auto made = [](const QString &family) {
QFont font(family);
font.setPixelSize(40);
font.setKerning(false);
font.setLetterSpacing(QFont::AbsoluteSpacing, 3);
font.setHintingPreference(QFont::PreferNoHinting);
return QFontMetricsF(font).horizontalAdvance(QStringLiteral("i"));
};
const double expected = 3 * made(QStringLiteral("DejaVu Sans")) + 2 * made(QStringLiteral("DejaVu Sans Mono"));
QVERIFY(qAbs(TextLines(sized, {10'000, 10'000}).usedSize().width() - expected) < 0.01);
// Colouring the run's letters keeps their face.
sized.setColor(PaletteColor{1, 0, 0}, {1, 2});
QVERIFY(sized.colorRuns && sized.fontRuns);
QVERIFY(qAbs(TextLines(sized, {10'000, 10'000}).usedSize().width() - expected) < 0.01);
// The face keeps the style's size and spacing.
style.tracking = 3;
QCOMPARE(TextLayout::font(style, mono).letterSpacing(), 3.0);
QCOMPARE(TextLayout::font(style, mono).pixelSize(), 72);
QCOMPARE(TextLayout::font(style, mono).family(), QStringLiteral("DejaVu Sans Mono"));
// An unlisted face asks Qt by its own name.
QCOMPARE(TextLayout::font(style, QStringLiteral("Zapfino")).family(), QStringLiteral("Zapfino"));
}
void TextFontRunsTests::newTextStartsInOneFace()
{
const auto session = makeSession();
session->beginText(QPointF(30, 40));
TextDraft draft = session->textDraft().value();
draft.style.content = QStringLiteral("Hello");
draft.style.fontName = sans;
session->setTextDraft(draft);
session->changeTextStyle([](LayerTextStyle &style) { style.setFont(mono, {0, 2}); });
QVERIFY(session->finishText());
session->beginText(QPointF(300, 300), true);
QVERIFY(!session->textDraft().value().style.fontRuns);
QCOMPARE(session->textDraft().value().style.fontName, sans);
}
QTEST_MAIN(TextFontRunsTests)
#include "TextFontRunsTests.moc"