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

+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"