Add a text effect

Writes a line or several over its input in a chosen family, weight, size
and colour, optionally on a box filled with a colour or with a picture
from disk. Like the blur and the overlay it needs no sources of its own,
so it can lead a chain; given sources it fills the canvas from a randomly
chosen one first.

Two param types rather than effect-specific branches in the front-ends:
Text, free-form text the user types, and Color, a colour written
"#rrggbb". Both travel as strings, so the interface gets an entry and a
colour button, the command line gets a flag (a colour that does not parse
is refused there rather than drawn as black), and a project keeps them
across a round trip.

The glyphs come from Pango, which draws through Cairo without a display,
so the core stays headless — but they are the one thing here that is not
byte-identical across machines: a machine without the chosen family
renders the substitute its fontconfig picks. Everything the effect is
handed, the rng included, is used exactly as every other effect uses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-18 20:31:24 +02:00
co-authored by Claude Opus 5
parent c34bc426fc
commit 1d0343b90a
12 changed files with 725 additions and 6 deletions
+190
View File
@@ -25,6 +25,7 @@
#include "effects/monochrome.hpp"
#include "effects/overlay.hpp"
#include "effects/scanlines.hpp"
#include "effects/text.hpp"
namespace {
@@ -129,6 +130,15 @@ effector::Image renderOverlay(const std::vector<effector::Image>& sources, int w
return effect.render(sources, width, height, rng, merged);
}
effector::Image renderText(const std::vector<effector::Image>& sources, int width, int height,
const effector::ParamValues& values) {
effector::TextEffect effect;
effector::Rng rng(7);
auto merged = effect.defaults();
for (const auto& [key, value] : values) merged[key] = value;
return effect.render(sources, width, height, rng, merged);
}
// The three channels of one pixel, to read a placement off directly.
std::array<int, 3> pixel(const effector::Image& image, int x, int y) {
const auto* px = image.row(y) + static_cast<std::size_t>(x) * 3;
@@ -533,6 +543,186 @@ int main() {
std::filesystem::remove_all(dir);
}
std::cout << "colour params\n";
{
std::uint8_t rgb[3] = {0, 0, 0};
check(parseColor("#ff8800", rgb) && rgb[0] == 255 && rgb[1] == 136 && rgb[2] == 0,
"a six digit colour is read");
check(parseColor("00FF80", rgb) && rgb[1] == 255 && rgb[2] == 128,
"the hash is optional and the case does not matter");
check(parseColor("#f80", rgb) && rgb[0] == 255 && rgb[1] == 136 && rgb[2] == 0,
"the three digit shorthand doubles each digit");
check(!parseColor("#12345", rgb) && !parseColor("#gg0000", rgb) && !parseColor("", rgb),
"anything else is refused");
const std::uint8_t written[3] = {1, 171, 255};
check(formatColor(written) == "#01abff", "a colour is written back as six digits");
// A value that does not parse falls back rather than painting
// something arbitrary.
std::uint8_t fallback[3] = {0, 0, 0};
paramColor({{"c", std::string("nonsense")}}, "c", "#102030", fallback);
check(fallback[0] == 0x10 && fallback[1] == 0x20 && fallback[2] == 0x30,
"a malformed colour keeps the default");
// Both new types survive a project round trip, and the reader drops a
// colour it cannot read exactly as it drops an unknown choice.
Project project;
project.width = 64;
project.height = 64;
project.stages.push_back({effectByName("text"),
{{"text", std::string("a \"quoted\" line")},
{"text_color", std::string("#ff0080")}}});
const auto reread = projectFromJson(projectToJson(project, ""), "");
check(paramText(reread.stages[0].values, "text", "") == "a \"quoted\" line",
"text with quotes in it survives a project round trip");
check(paramText(reread.stages[0].values, "text_color", "") == "#ff0080",
"and so does a colour");
const auto broken = projectFromJson(
"{\"chain\": [{\"effect\": \"text\", \"params\": {\"text_color\": \"#zz\"}}]}", "");
check(paramText(broken.stages[0].values, "text_color", "") == "#ffffff",
"a colour a project cannot be read keeps the effect's default");
// The command line refuses one too, rather than drawing black.
bool threw = false;
try {
const char* argv[] = {"effector", "-e", "text", "--text-color", "purple"};
parseOptions(5, const_cast<char**>(argv));
} catch (const EffectError&) {
threw = true;
}
check(threw, "an unparseable colour on the command line is reported");
}
std::cout << "text\n";
{
const std::vector<Image> blue = {solid(200, 120, 0, 0, 255)};
const ParamValues plain = {{"text", std::string("Hi")},
{"font_size", 60.0},
{"text_color", std::string("#ff0000")}};
// Counting the red pixels is how every placement below is read: the
// exact glyphs belong to whatever font this machine has.
const auto countRed = [](const Image& image, int x0, int y0, int x1, int y1) {
int found = 0;
for (int y = y0; y < y1; ++y) {
for (int x = x0; x < x1; ++x) {
if (isRed(pixel(image, x, y))) ++found;
}
}
return found;
};
const auto centred = renderText(blue, 200, 120, plain);
check(countRed(centred, 60, 30, 140, 90) > 0, "the text lands in the middle of the canvas");
check(countRed(centred, 0, 0, 200, 120) == countRed(centred, 50, 20, 150, 100),
"and nothing is written outside it");
ParamValues corner = plain;
corner["text_center"] = false;
const auto topLeft = renderText(blue, 200, 120, corner);
check(countRed(topLeft, 0, 0, 100, 60) > 0 && countRed(topLeft, 100, 60, 200, 120) == 0,
"uncentred it starts at the top left corner");
ParamValues nudged = corner;
nudged["text_offset_x"] = 100L;
nudged["text_offset_y"] = 60L;
const auto moved = renderText(blue, 200, 120, nudged);
check(countRed(moved, 100, 60, 200, 120) > 0 && countRed(moved, 0, 0, 100, 60) == 0,
"the offset moves it down and to the right");
// Weight and size are the two knobs the glyphs themselves answer to: a
// bolder or a larger setting covers strictly more of the canvas.
ParamValues bold = plain;
bold["font_weight"] = std::string("heavy");
check(countRed(renderText(blue, 200, 120, bold), 0, 0, 200, 120) >
countRed(centred, 0, 0, 200, 120),
"a heavier weight inks more pixels than a normal one");
ParamValues large = plain;
large["font_size"] = 90.0;
check(countRed(renderText(blue, 200, 120, large), 0, 0, 200, 120) >
countRed(centred, 0, 0, 200, 120),
"and so does a larger size");
// The box is drawn under the text, in a colour or from a picture.
ParamValues boxed = plain;
boxed["text_box"] = std::string("colour");
boxed["box_color"] = std::string("#00ff00");
boxed["box_padding"] = 10L;
const auto onBox = renderText(blue, 200, 120, boxed);
const auto corner_px = pixel(onBox, 2, 2);
check(corner_px[2] > 200, "the box leaves the canvas outside it alone");
bool greenSomewhere = false;
for (int y = 0; y < 120 && !greenSomewhere; ++y) {
for (int x = 0; x < 200; ++x) {
const auto px = pixel(onBox, x, y);
if (px[1] > 200 && px[0] < 60 && px[2] < 60) greenSomewhere = true;
}
}
check(greenSomewhere, "and is filled with the colour it was given");
check(countRed(onBox, 0, 0, 200, 120) > 0, "the text is drawn over the box");
ParamValues faint = boxed;
faint["box_opacity"] = 0.5;
const auto half = renderText(blue, 200, 120, faint);
bool mixed = false;
for (int y = 0; y < 120 && !mixed; ++y) {
for (int x = 0; x < 200; ++x) {
const auto px = pixel(half, x, y);
// Half the green box over the blue input, and neither of them.
if (px[1] > 100 && px[1] < 200 && px[2] > 100 && px[2] < 200) mixed = true;
}
}
check(mixed, "half a box opacity sits between the box and the input");
const auto dir = std::filesystem::temp_directory_path() / "effector_test_text";
std::filesystem::create_directories(dir);
const auto picture = (dir / "fill.png").string();
solid(40, 40, 255, 255, 0).save(picture);
ParamValues fromPicture = boxed;
fromPicture["text_box"] = std::string("image");
fromPicture["box_image"] = picture;
const auto onPicture = renderText(blue, 200, 120, fromPicture);
bool yellowSomewhere = false;
for (int y = 0; y < 120 && !yellowSomewhere; ++y) {
for (int x = 0; x < 200; ++x) {
const auto px = pixel(onPicture, x, y);
if (px[0] > 200 && px[1] > 200 && px[2] < 60) yellowSomewhere = true;
}
}
check(yellowSomewhere, "a box picture fills the box");
check(pixel(onPicture, 2, 2)[2] > 200, "and stops at its edge");
bool threw = false;
try {
ParamValues missing = fromPicture;
missing["box_image"] = std::string("/nope/missing.png");
renderText(blue, 200, 120, missing);
} catch (const EffectError&) {
threw = true;
}
check(threw, "a missing box picture is reported");
// Nothing to draw is the state the interface starts in: a pass through.
check(sameBytes(renderText(blue, 200, 120, {{"text", std::string("")}}),
solid(200, 120, 0, 0, 255)),
"with no text typed the stage passes its input through");
check(sameBytes(renderText(blue, 200, 120, {{"text", std::string("Hi")},
{"text_opacity", 0.0}}),
solid(200, 120, 0, 0, 255)),
"zero opacity leaves the input untouched");
// Like blur and overlay it needs no sources of its own, and the same
// settings render the same bytes twice over.
check(!TextEffect().needsSources(), "text can lead a chain");
const auto alone = renderText({}, 200, 120, plain);
check(alone.width() == 200 && countRed(alone, 0, 0, 200, 120) > 0,
"without sources it draws on an empty canvas");
check(sameBytes(renderText(blue, 200, 120, boxed), onBox),
"the same settings render the same bytes");
std::filesystem::remove_all(dir);
}
std::cout << "chain\n";
{
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};