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:
@@ -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)};
|
||||
|
||||
Reference in New Issue
Block a user