blob: c06b34583a4d5832e4b11f1daa457603b8e885b3 [file]
#include "rive/simple_array.hpp"
#include "catch.hpp"
#include "rive/text_engine.hpp"
#include "rive/text/font_hb.hpp"
#include "rive/text/text_style_geometry.hpp"
#include "rive/text/utf.hpp"
#include "hb.h"
#include "hb-ot.h"
#include <string>
using namespace rive;
static rive::TextRun append(
std::vector<rive::Unichar>* unichars,
rive::rcp<rive::Font> font,
float size,
const char text[])
{
const uint8_t* ptr = (const uint8_t*)text;
const uint8_t* end = ptr + strlen(text);
uint32_t n = 0;
while (ptr < end)
{
unichars->push_back(rive::UTF::NextUTF8(&ptr, end));
n += 1;
}
return {std::move(font), size, -1.0f, 0.0f, n, 0};
}
static std::vector<uint8_t> readBytes(const char* filename)
{
FILE* fp = fopen(filename, "rb");
REQUIRE(fp != nullptr);
fseek(fp, 0, SEEK_END);
const size_t length = ftell(fp);
fseek(fp, 0, SEEK_SET);
std::vector<uint8_t> bytes(length);
REQUIRE(fread(bytes.data(), 1, length, fp) == length);
fclose(fp);
return bytes;
}
static rcp<Font> loadFont(const char* filename)
{
auto bytes = readBytes(filename);
return HBFont::Decode(bytes);
}
static std::vector<rive::rcp<rive::Font>> fallbackFonts;
static rive::rcp<rive::Font> pickFallbackFont(
const rive::Unichar missing,
const uint32_t fallbackIndex,
const rive::Font*)
{
if (fallbackIndex > 0)
{
return nullptr;
}
size_t length = fallbackFonts.size();
for (size_t i = fallbackIndex; i < length; i++)
{
HBFont* font = static_cast<HBFont*>(fallbackFonts[i].get());
if (font->hasGlyph(missing))
{
return fallbackFonts[i];
}
}
return nullptr;
}
TEST_CASE("Inspect Font Styles", "[text_styles]")
{
struct TestCaseData
{
const char* fontPath;
uint16_t expectedWeight;
bool expectedItalic;
};
std::vector<TestCaseData> testCases = {
{"assets/fonts/AdventPro-VariableFont_wdth,wght.ttf", 400, false},
{"assets/fonts/Inter_18pt-Regular.ttf", 400, false},
{"assets/fonts/Inter_28pt-Bold.ttf", 700, false},
{"assets/fonts/OpenSans-Italic.ttf", 400, true},
{"assets/fonts/OpenSans-ExtraBoldItalic.ttf", 800, true},
};
for (const auto& testCase : testCases)
{
SECTION(testCase.fontPath)
{
rive::rcp<Font> font = loadFont(testCase.fontPath);
HBFont* hbFont = static_cast<HBFont*>(font.get());
REQUIRE(hbFont->getWeight() == testCase.expectedWeight);
REQUIRE(hbFont->isItalic() == testCase.expectedItalic);
}
}
}
TEST_CASE("font exposes cap and x height for vertical trim", "[text_styles]")
{
// Latin text fonts have an 'H' and 'x'; their tops must sit between the
// baseline and the ascent, with the x-height below the cap-height. All of
// capHeight/xHeight/ascent are stored negative (up is -Y).
std::vector<const char*> fontPaths = {
"assets/fonts/Inter_18pt-Regular.ttf",
"assets/Montserrat.ttf",
};
for (const char* path : fontPaths)
{
SECTION(path)
{
rive::rcp<Font> font = loadFont(path);
const Font::LineMetrics& metrics = font->lineMetrics();
REQUIRE(metrics.capHeight < 0.0f);
REQUIRE(metrics.capHeight >= metrics.ascent);
// x-height is below the cap-height (less far above the baseline).
REQUIRE(metrics.xHeight > metrics.capHeight);
REQUIRE(metrics.xHeight < 0.0f);
// The size-scaled accessors scale linearly.
REQUIRE(font->capHeight(20.0f) == Approx(metrics.capHeight * 20.0f));
REQUIRE(font->xHeight(20.0f) == Approx(metrics.xHeight * 20.0f));
}
}
}
TEST_CASE("fallback glyphs are found", "[text_fallback]")
{
REQUIRE(fallbackFonts.empty());
auto font = loadFont("assets/RobotoFlex.ttf");
REQUIRE(font != nullptr);
auto fallbackFont = loadFont("assets/IBMPlexSansArabic-Regular.ttf");
REQUIRE(fallbackFont != nullptr);
fallbackFonts.push_back(fallbackFont);
Font::gFallbackProc = pickFallbackFont;
std::vector<rive::TextRun> truns;
std::vector<rive::Unichar> unichars;
truns.push_back(append(&unichars, font, 32.0f, "لمفاتيح ABC DEF"));
auto paragraphs = font->shapeText(unichars, truns);
REQUIRE(paragraphs.size() == 1);
paragraphs = SimpleArray<Paragraph>();
REQUIRE(paragraphs.size() == 0);
fallbackFonts.clear();
Font::gFallbackProc = nullptr;
}
TEST_CASE("variable axis values can be read", "[text]")
{
REQUIRE(fallbackFonts.empty());
auto font = loadFont("assets/RobotoFlex.ttf");
REQUIRE(font != nullptr);
auto count = font->getAxisCount();
bool hasWeight = false;
for (uint16_t i = 0; i < count; i++)
{
auto axis = font->getAxis(i);
if (axis.tag == 2003265652)
{
REQUIRE(axis.def == 400.0f);
hasWeight = true;
break;
}
}
REQUIRE(hasWeight);
float value = font->getAxisValue(2003265652);
REQUIRE(value == 400.0f);
REQUIRE(font->getAxisValue(2003072104) == 100.0f);
rive::Font::Coord coord = {2003265652, 800.0f};
rive::rcp<rive::Font> vfont = font->makeAtCoords(
rive::Span<HBFont::Coord>(&coord, 1));
REQUIRE(vfont->getAxisValue(2003265652) == 800.0f);
rive::Font::Coord coord2 = {2003072104, 122.0f};
rive::rcp<rive::Font> vfont2 = vfont->makeAtCoords(
rive::Span<HBFont::Coord>(&coord2, 1));
REQUIRE(vfont2->getAxisValue(2003072104) == 122.0f);
// Should also still have the first axis value we set.
REQUIRE(vfont2->getAxisValue(2003265652) == 800.0f);
}
static std::string tagToString(uint32_t tag)
{
std::string tag_name;
tag_name += ((char)((tag & 0xff000000) >> 24));
tag_name += ((char)((tag & 0x00ff0000) >> 16));
tag_name += ((char)((tag & 0x0000ff00) >> 8));
tag_name += ((char)((tag & 0x000000ff)));
return tag_name;
}
static bool hasTag(std::vector<std::string> featureStrings, std::string tag)
{
return std::find(std::begin(featureStrings), std::end(featureStrings), tag) !=
std::end(featureStrings);
}
TEST_CASE("font features load as expected", "[text]")
{
REQUIRE(fallbackFonts.empty());
auto font = loadFont("assets/RobotoFlex.ttf");
REQUIRE(font != nullptr);
rive::SimpleArray<uint32_t> features = font->features();
std::vector<std::string> featureStrings;
for (auto feature : features)
{
featureStrings.push_back(tagToString(feature));
}
REQUIRE(features.size() == 7);
REQUIRE(hasTag(featureStrings, "mkmk"));
REQUIRE(hasTag(featureStrings, "kern"));
REQUIRE(hasTag(featureStrings, "rvrn"));
REQUIRE(hasTag(featureStrings, "mark"));
REQUIRE(hasTag(featureStrings, "locl"));
REQUIRE(hasTag(featureStrings, "pnum"));
REQUIRE(hasTag(featureStrings, "liga"));
}
// DecodeFile maps the file instead of copying it. The mapping is invisible to
// callers, so these assert that it is equivalent to Decode and that every
// failure path degrades to null rather than crashing (callers fall back).
TEST_CASE("mapped font decodes equivalently to a copied one", "[text]")
{
const char* path = "assets/fonts/Inter_18pt-Regular.ttf";
#ifndef RIVE_HB_FILE_MAPPING
// No file mapping here (Windows): callers fall back to Decode.
REQUIRE(HBFont::DecodeFile(path) == nullptr);
return;
#else
auto copied = loadFont(path);
auto mapped = HBFont::DecodeFile(path);
REQUIRE(copied != nullptr);
REQUIRE(mapped != nullptr);
// Same metrics.
REQUIRE(mapped->getWeight() == copied->getWeight());
REQUIRE(mapped->isItalic() == copied->isItalic());
REQUIRE(mapped->getAxisCount() == copied->getAxisCount());
// Same shaping: identical glyphs and advances for the same text.
std::vector<rive::Unichar> mappedText, copiedText;
std::vector<rive::TextRun> mappedRuns, copiedRuns;
copiedRuns.push_back(append(&copiedText, copied, 32.0f, "Shaping parity"));
mappedRuns.push_back(append(&mappedText, mapped, 32.0f, "Shaping parity"));
auto copiedShape = copied->shapeText(copiedText, copiedRuns);
auto mappedShape = mapped->shapeText(mappedText, mappedRuns);
REQUIRE(mappedShape.size() == copiedShape.size());
for (size_t p = 0; p < copiedShape.size(); p++)
{
const auto& a = copiedShape[p];
const auto& b = mappedShape[p];
REQUIRE(b.runs.size() == a.runs.size());
for (size_t r = 0; r < a.runs.size(); r++)
{
REQUIRE(b.runs[r].glyphs.size() == a.runs[r].glyphs.size());
for (size_t g = 0; g < a.runs[r].glyphs.size(); g++)
{
REQUIRE(b.runs[r].glyphs[g] == a.runs[r].glyphs[g]);
REQUIRE(b.runs[r].advances[g] == a.runs[r].advances[g]);
}
}
}
#endif
}
TEST_CASE("mapped font outlives the call that created it", "[text]")
{
const char* path = "assets/fonts/Inter_18pt-Regular.ttf";
#ifndef RIVE_HB_FILE_MAPPING
REQUIRE(HBFont::DecodeFile(path) == nullptr);
return;
#else
// The mapping is owned by harfbuzz's blob, not by any local: glyph data
// must still be readable once DecodeFile's scope is long gone.
rcp<Font> font;
{
font = HBFont::DecodeFile(path);
}
REQUIRE(font != nullptr);
REQUIRE(font->hasGlyph('A'));
// Shaping reads the mapped tables, so this would fault or return nothing
// if the mapping had been released with the local scope.
std::vector<rive::Unichar> text;
std::vector<rive::TextRun> runs;
runs.push_back(append(&text, font, 32.0f, "A"));
auto shape = font->shapeText(text, runs);
REQUIRE(shape.size() == 1);
REQUIRE(shape[0].runs.size() == 1);
REQUIRE(shape[0].runs[0].glyphs.size() == 1);
REQUIRE(font->getPath(shape[0].runs[0].glyphs[0]).verbs().size() > 0);
#endif
}
TEST_CASE("mapped font opens the requested collection face", "[text]")
{
const char* path = "assets/fonts/two_faces.ttc";
#ifndef RIVE_HB_FILE_MAPPING
REQUIRE(HBFont::DecodeFile(path, 1) == nullptr);
return;
#else
// Face 0 covers a-f at weight 400, face 1 covers only a at weight 250.
auto first = HBFont::DecodeFile(path, 0);
auto second = HBFont::DecodeFile(path, 1);
REQUIRE(first != nullptr);
REQUIRE(second != nullptr);
REQUIRE(first->hasGlyph('b'));
REQUIRE(second->hasGlyph('a'));
REQUIRE_FALSE(second->hasGlyph('b'));
REQUIRE(first->getWeight() != second->getWeight());
REQUIRE(HBFont::DecodeFile(path, 2) == nullptr);
#endif
}
TEST_CASE(
"mapped coverage probes respect collection faces and promote safely",
"[text]")
{
const char* path = "assets/fonts/two_faces.ttc";
#ifndef RIVE_HB_FILE_MAPPING
REQUIRE(HBFont::ProbeFile(path) == nullptr);
#else
auto first = HBFont::ProbeFile(path, 0);
auto second = HBFont::ProbeFile(path, 1);
REQUIRE(first != nullptr);
REQUIRE(second != nullptr);
REQUIRE(first->hasGlyph('b'));
REQUIRE(second->hasGlyph('a'));
REQUIRE_FALSE(second->hasGlyph('b'));
REQUIRE_FALSE(first->hasGlyph(0x10FFFF));
REQUIRE(HBFont::ProbeFile(path, 2) == nullptr);
auto font = first->makeFont();
REQUIRE(font != nullptr);
REQUIRE_FALSE(first->hasGlyph('b'));
REQUIRE(first->makeFont() == nullptr);
first.reset();
second.reset();
REQUIRE(font->hasGlyph('b'));
REQUIRE(font->getWeight() == 400);
// Shaping and drawing must still work after the probes release their
// references.
std::vector<rive::Unichar> text;
std::vector<rive::TextRun> runs;
runs.push_back(append(&text, font, 32.0f, "b"));
auto shape = font->shapeText(text, runs);
REQUIRE(shape.size() == 1);
REQUIRE(shape[0].runs[0].glyphs.size() == 1);
REQUIRE(font->getPath(shape[0].runs[0].glyphs[0]).verbs().size() > 0);
#endif
}
TEST_CASE("a font lends its cached glyph path", "[text]")
{
auto font = loadFont("assets/fonts/Inter_18pt-Regular.ttf");
REQUIRE(font != nullptr);
// The first ask draws the outline and keeps it; later asks lend that same
// path rather than a copy, and getPath copies exactly what it lends.
const GlyphID glyph = 36;
const RawPath& lent = font->glyphPath(glyph);
REQUIRE(!lent.empty());
CHECK(&font->glyphPath(glyph) == &lent);
RawPath copy = font->getPath(glyph);
CHECK(copy == lent);
CHECK(&font->glyphPath(glyph) == &lent);
}
TEST_CASE("a glyph added with its placement winds as placed", "[text]")
{
// A counterclockwise dot in em units, drawn with just two cubics. Its
// winding only shows once it's drawn large enough for the curves to count.
RawPath dot;
dot.moveTo(0.25f, 0);
dot.cubicTo(0.25f, -0.33f, -0.25f, -0.33f, -0.25f, 0);
dot.cubicTo(-0.25f, 0.33f, 0.25f, 0.33f, 0.25f, 0);
dot.close();
TextStyleGeometry geometry;
const Mat2D placement = Mat2D::fromScaleAndTranslation(100, 100, 300, 200);
REQUIRE(geometry.addPath(dot, 0.5f, &placement));
auto clockwise = [](const ShapePaintPath& path) {
const RawPath& raw = *path.rawPath();
return raw.computeCoarseArea(raw.bounds().center()) > 0;
};
CHECK(clockwise(*geometry.path()));
REQUIRE(geometry.opacityPaths().size() == 1);
CHECK(clockwise(geometry.opacityPaths().begin()->second));
}
TEST_CASE("owned font bytes are adopted rather than copied", "[text]")
{
rcp<Font> font;
const uint8_t* data;
{
auto bytes = readBytes("assets/fonts/Inter_18pt-Regular.ttf");
data = bytes.data();
font = HBFont::Decode(std::move(bytes));
}
REQUIRE(font != nullptr);
hb_blob_t* blob = hb_face_reference_blob(
hb_font_get_face(static_cast<HBFont*>(font.get())->font()));
REQUIRE(
reinterpret_cast<const uint8_t*>(hb_blob_get_data(blob, nullptr)) ==
data);
hb_blob_destroy(blob);
// Shaping reads the adopted bytes after the caller's scope is gone.
std::vector<rive::Unichar> text;
std::vector<rive::TextRun> runs;
runs.push_back(append(&text, font, 32.0f, "A"));
auto shape = font->shapeText(text, runs);
REQUIRE(shape.size() == 1);
REQUIRE(shape[0].runs[0].glyphs.size() == 1);
REQUIRE(font->getPath(shape[0].runs[0].glyphs[0]).verbs().size() > 0);
REQUIRE(HBFont::Decode(std::vector<uint8_t>()) == nullptr);
}
TEST_CASE("DecodeFile returns null rather than failing hard", "[text]")
{
// Callers treat null as "use Decode instead", so every failure this call
// can detect must return null rather than throw or crash. Note these are
// only the failures detectable here: arbitrary non-font data is NOT one of
// them, since hb_face_create_or_fail does not validate that a non-empty
// blob is a font -- the byte-based Decode has the same behaviour.
REQUIRE(HBFont::DecodeFile(nullptr) == nullptr);
REQUIRE(HBFont::DecodeFile("assets/does_not_exist.ttf") == nullptr);
REQUIRE(HBFont::ProbeFile(nullptr) == nullptr);
REQUIRE(HBFont::ProbeFile("assets/does_not_exist.ttf") == nullptr);
// An empty file: rejected on size before anything is mapped.
const char* emptyPath = "assets/empty_font_test_file.tmp";
FILE* fp = fopen(emptyPath, "wb");
REQUIRE(fp != nullptr);
fclose(fp);
REQUIRE(HBFont::DecodeFile(emptyPath) == nullptr);
REQUIRE(HBFont::ProbeFile(emptyPath) == nullptr);
remove(emptyPath);
}
// Shapes "ab " in [first] then a long word in [second], all on one line, and
// returns the ellipsis that cutting it at [lineWidth] produces.
static GlyphRun ellipsisFor(
const TextRun& first,
const TextRun& second,
float lineWidth)
{
std::vector<Unichar> unichars;
std::vector<TextRun> runs;
runs.push_back(append(&unichars, first.font, first.size, "ab "));
runs.push_back(
append(&unichars, second.font, second.size, "cdefghijklmnopqrstuvwxyz"));
auto shape = first.font->shapeText(unichars, runs);
REQUIRE(shape.size() == 1);
auto lines = GlyphLine::BreakLines(shape[0].runs, 10000.0f);
REQUIRE(lines.size() == 1);
GlyphRun ellipsisRun = {};
OrderedLine line(
shape[0],
lines[0],
lineWidth,
TextAlign::left,
true,
true,
&ellipsisRun,
0);
REQUIRE(ellipsisRun.font != nullptr);
return ellipsisRun;
}
// The line is cut inside the second run, where an ellipsis in that run's font
// and size still fits, so the ellipsis takes them. A run that changes only one
// of the two must still reshape it.
TEST_CASE("ellipsis follows a run that changes only its size", "[text]")
{
auto font = loadFont("assets/RobotoFlex.ttf");
REQUIRE(font != nullptr);
auto ellipsis = ellipsisFor(
{font, 10.0f, -1.0f, 0.0f, 0, 0},
{font, 30.0f, -1.0f, 0.0f, 0, 0},
120.0f);
CHECK(ellipsis.size == 30.0f);
}
TEST_CASE("ellipsis follows a run that changes only its font", "[text]")
{
auto roboto = loadFont("assets/RobotoFlex.ttf");
auto inter = loadFont("assets/fonts/Inter_18pt-Regular.ttf");
REQUIRE(roboto != nullptr);
REQUIRE(inter != nullptr);
auto ellipsis = ellipsisFor(
{roboto, 20.0f, -1.0f, 0.0f, 0, 0},
{inter, 20.0f, -1.0f, 0.0f, 0, 0},
120.0f);
CHECK(ellipsis.font == inter);
}