Files
effector/src/effects/text.cpp
T
SiandClaude Opus 5 1d0343b90a 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>
2026-09-18 20:31:24 +02:00

307 lines
14 KiB
C++

#include "effects/text.hpp"
#include <pango/pangocairo.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& TextEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeText("text", "Text", "Effector",
"What to write; a line break starts a new line."),
ParamSpec::makeText("font_family", "Font", "Sans",
"Font family, as it is named on this machine: Sans, Serif, Monospace, "
"\"DejaVu Serif\", ..."),
ParamSpec::makeChoice("font_weight", "Weight", "normal",
{"thin", "light", "normal", "medium", "semibold", "bold", "heavy"},
"How bold the glyphs are; a family without the chosen weight is "
"rendered in the nearest one it has."),
ParamSpec::makeBool("font_italic", "Italic", false, "Slants the glyphs."),
ParamSpec::makeDouble("font_size", "Size", 96.0, 4.0, 2000.0,
"Height of the glyphs in pixels.", 1.0, 1),
ParamSpec::makeColor("text_color", "Colour", "#ffffff", "The colour of the glyphs."),
ParamSpec::makeChoice("text_align", "Alignment", "center", {"left", "center", "right"},
"How several lines line up against each other."),
ParamSpec::makeInt("text_wrap", "Wrap width", 0, 0, 20000,
"Breaks the text into lines no wider than this many pixels; 0 writes "
"it as given."),
ParamSpec::makeChoice("text_box", "Box", "none", {"none", "colour", "image"},
"What the text sits on: nothing, a filled rectangle, or a picture "
"from disk cropped to the rectangle."),
ParamSpec::makeColor("box_color", "Box colour", "#000000",
"The colour the box is filled with."),
ParamSpec::makePath("box_image", "Box picture",
"The picture the box is filled with, scaled to cover it."),
ParamSpec::makeInt("box_padding", "Box padding", 24, 0, 2000,
"How far the box reaches past the text on every side."),
ParamSpec::makeDouble("box_opacity", "Box opacity", 1.0, 0.0, 1.0,
"How strongly the box is mixed over the input."),
ParamSpec::makeBool("text_center", "Centre", true,
"Places the text in the middle of the canvas rather than at its top "
"left corner; the offset is counted from there either way."),
ParamSpec::makeInt("text_offset_x", "Offset X", 0, -20000, 20000,
"Moves the text right by this many pixels (left when negative)."),
ParamSpec::makeInt("text_offset_y", "Offset Y", 0, -20000, 20000,
"Moves the text down by this many pixels (up when negative)."),
ParamSpec::makeDouble("text_opacity", "Text opacity", 1.0, 0.0, 1.0,
"How strongly the glyphs are mixed over what is behind them."),
};
return specs;
}
namespace {
// Small holders so that an EffectError thrown half way through still frees the
// Cairo and Pango objects.
struct SurfaceHandle {
cairo_surface_t* get = nullptr;
~SurfaceHandle() {
if (get != nullptr) cairo_surface_destroy(get);
}
};
struct ContextHandle {
cairo_t* get = nullptr;
~ContextHandle() {
if (get != nullptr) cairo_destroy(get);
}
};
struct LayoutHandle {
PangoLayout* get = nullptr;
~LayoutHandle() {
if (get != nullptr) g_object_unref(get);
}
};
struct FontHandle {
PangoFontDescription* get = nullptr;
~FontHandle() {
if (get != nullptr) pango_font_description_free(get);
}
};
PangoWeight weightFor(const std::string& name) {
if (name == "thin") return PANGO_WEIGHT_THIN;
if (name == "light") return PANGO_WEIGHT_LIGHT;
if (name == "medium") return PANGO_WEIGHT_MEDIUM;
if (name == "semibold") return PANGO_WEIGHT_SEMIBOLD;
if (name == "bold") return PANGO_WEIGHT_BOLD;
if (name == "heavy") return PANGO_WEIGHT_HEAVY;
return PANGO_WEIGHT_NORMAL;
}
PangoAlignment alignmentFor(const std::string& name) {
if (name == "left") return PANGO_ALIGN_LEFT;
if (name == "right") return PANGO_ALIGN_RIGHT;
return PANGO_ALIGN_CENTER;
}
// An RGB image plus its alpha as a Cairo ARGB32 surface, premultiplied, which
// is the only layout Cairo paints from.
cairo_surface_t* toSurface(const Image& image, const std::vector<std::uint8_t>& alpha) {
cairo_surface_t* surface =
cairo_image_surface_create(CAIRO_FORMAT_ARGB32, image.width(), image.height());
if (cairo_surface_status(surface) != CAIRO_STATUS_SUCCESS) {
cairo_surface_destroy(surface);
throw EffectError("Cannot hold the box picture in memory.");
}
const int stride = cairo_image_surface_get_stride(surface);
unsigned char* data = cairo_image_surface_get_data(surface);
const bool hasAlpha = alpha.size() == static_cast<std::size_t>(image.width()) *
static_cast<std::size_t>(image.height());
for (int y = 0; y < image.height(); ++y) {
const std::uint8_t* in = image.row(y);
auto* out = reinterpret_cast<std::uint32_t*>(data + static_cast<std::size_t>(y) * stride);
for (int x = 0; x < image.width(); ++x) {
const std::uint32_t a =
hasAlpha ? alpha[static_cast<std::size_t>(y) * image.width() + x] : 255;
const std::uint32_t r = in[x * 3 + 0] * a / 255;
const std::uint32_t g = in[x * 3 + 1] * a / 255;
const std::uint32_t b = in[x * 3 + 2] * a / 255;
out[x] = (a << 24) | (r << 16) | (g << 8) | b;
}
}
cairo_surface_mark_dirty(surface);
return surface;
}
void setColor(cairo_t* cr, const std::uint8_t rgb[3], double opacity) {
cairo_set_source_rgba(cr, rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, opacity);
}
// Mixes the drawn layer, ARGB32 and premultiplied, over the canvas.
void composite(Image& canvas, const cairo_surface_t* surface) {
const int stride = cairo_image_surface_get_stride(const_cast<cairo_surface_t*>(surface));
const unsigned char* data =
cairo_image_surface_get_data(const_cast<cairo_surface_t*>(surface));
const int width = canvas.width();
parallelFor(static_cast<std::size_t>(canvas.height()), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
const auto* in =
reinterpret_cast<const std::uint32_t*>(data + static_cast<std::size_t>(y) * stride);
std::uint8_t* out = canvas.row(static_cast<int>(y));
for (int x = 0; x < width; ++x) {
const std::uint32_t pixel = in[x];
const std::uint32_t a = pixel >> 24;
if (a == 0) continue;
// Premultiplied: the colour is already scaled by alpha, so the
// mix is an addition once the background is scaled down.
const std::uint32_t channel[3] = {(pixel >> 16) & 0xFF, (pixel >> 8) & 0xFF,
pixel & 0xFF};
std::uint8_t* px = out + static_cast<std::size_t>(x) * 3;
for (int c = 0; c < 3; ++c) {
const std::uint32_t value = channel[c] + px[c] * (255 - a) / 255;
px[c] = static_cast<std::uint8_t>(std::min<std::uint32_t>(value, 255));
}
}
}
});
}
} // namespace
void TextEffect::loadBoxImage(const std::string& path, Image& image,
std::vector<std::uint8_t>& alpha) const {
std::lock_guard<std::mutex> lock(cacheMutex_);
if (cachedPath_ != path || cached_.empty()) {
std::vector<std::uint8_t> loadedAlpha;
Image loaded;
try {
loaded = Image::load(path, &loadedAlpha);
} catch (const std::exception& error) {
throw EffectError("Cannot read the box picture '" + path + "': " + error.what());
}
if (loaded.empty()) throw EffectError("The box picture '" + path + "' is empty.");
cached_ = std::move(loaded);
cachedAlpha_ = std::move(loadedAlpha);
cachedPath_ = path;
}
image = cached_;
alpha = cachedAlpha_;
}
Image TextEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
Image canvas(width, height);
if (!sources.empty()) {
// Drawn before any threading, so that a seed always gives the same image.
const std::size_t index = rng.below(sources.size() - 1);
const Image& src = sources[index];
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - height)));
fillFromSource(canvas, src, sx, sy);
}
const std::string text = paramText(values, "text", "");
const double textOpacity = std::clamp(paramDouble(values, "text_opacity", 1.0), 0.0, 1.0);
const std::string boxKind = paramChoice(values, "text_box", "none");
const double boxOpacity = std::clamp(paramDouble(values, "box_opacity", 1.0), 0.0, 1.0);
const bool drawsText = !text.empty() && textOpacity > 0.0;
const bool drawsBox = boxKind != "none" && boxOpacity > 0.0;
// No text typed yet is the state the interface starts in, not an error: the
// stage simply passes its input through.
if (!drawsText && !drawsBox) return canvas;
SurfaceHandle surface;
surface.get = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height);
if (cairo_surface_status(surface.get) != CAIRO_STATUS_SUCCESS) {
throw EffectError("Cannot hold the text layer in memory.");
}
ContextHandle cr;
cr.get = cairo_create(surface.get);
LayoutHandle layout;
layout.get = pango_cairo_create_layout(cr.get);
pango_layout_set_text(layout.get, text.c_str(), -1);
pango_layout_set_alignment(layout.get, alignmentFor(paramChoice(values, "text_align", "center")));
const int wrap = static_cast<int>(paramInt(values, "text_wrap", 0));
if (wrap > 0) {
pango_layout_set_width(layout.get, wrap * PANGO_SCALE);
pango_layout_set_wrap(layout.get, PANGO_WRAP_WORD_CHAR);
}
FontHandle font;
font.get = pango_font_description_new();
const std::string family = paramText(values, "font_family", "Sans");
pango_font_description_set_family(font.get, family.empty() ? "Sans" : family.c_str());
pango_font_description_set_weight(font.get, weightFor(paramChoice(values, "font_weight", "normal")));
pango_font_description_set_style(
font.get, paramBool(values, "font_italic", false) ? PANGO_STYLE_ITALIC : PANGO_STYLE_NORMAL);
// Absolute size: the param is in pixels, not in points at some unstated dpi.
pango_font_description_set_absolute_size(
font.get, std::clamp(paramDouble(values, "font_size", 96.0), 4.0, 2000.0) * PANGO_SCALE);
pango_layout_set_font_description(layout.get, font.get);
PangoRectangle logical;
pango_layout_get_pixel_extents(layout.get, nullptr, &logical);
const int padding = static_cast<int>(paramInt(values, "box_padding", 24));
const int boxWidth = std::max(1, logical.width + 2 * padding);
const int boxHeight = std::max(1, logical.height + 2 * padding);
const bool centre = paramBool(values, "text_center", true);
const double boxX = (centre ? (width - boxWidth) / 2.0 : 0.0) +
static_cast<double>(paramInt(values, "text_offset_x", 0));
const double boxY = (centre ? (height - boxHeight) / 2.0 : 0.0) +
static_cast<double>(paramInt(values, "text_offset_y", 0));
if (drawsBox) {
cairo_save(cr.get);
cairo_rectangle(cr.get, boxX, boxY, boxWidth, boxHeight);
cairo_clip(cr.get);
if (boxKind == "image") {
const std::string path = paramText(values, "box_image", "");
if (!path.empty()) {
Image picture;
std::vector<std::uint8_t> alpha;
loadBoxImage(path, picture, alpha);
SurfaceHandle fill;
fill.get = toSurface(picture, alpha);
// Scaled to cover the box, keeping its proportions, exactly as
// the overlay's "fill" does; what does not fit is clipped away.
const double factor = std::max(static_cast<double>(boxWidth) / picture.width(),
static_cast<double>(boxHeight) / picture.height());
cairo_translate(cr.get, boxX + (boxWidth - picture.width() * factor) / 2.0,
boxY + (boxHeight - picture.height() * factor) / 2.0);
cairo_scale(cr.get, factor, factor);
cairo_set_source_surface(cr.get, fill.get, 0.0, 0.0);
cairo_pattern_set_filter(cairo_get_source(cr.get), CAIRO_FILTER_BILINEAR);
cairo_paint_with_alpha(cr.get, boxOpacity);
}
} else {
std::uint8_t rgb[3];
paramColor(values, "box_color", "#000000", rgb);
setColor(cr.get, rgb, boxOpacity);
cairo_paint(cr.get);
}
cairo_restore(cr.get);
}
if (drawsText) {
std::uint8_t rgb[3];
paramColor(values, "text_color", "#ffffff", rgb);
setColor(cr.get, rgb, textOpacity);
// logical.x/y are where the layout actually starts, which a wrapped or
// right-aligned layout shifts; subtracting them puts the glyphs inside
// the padding on every side.
cairo_move_to(cr.get, boxX + padding - logical.x, boxY + padding - logical.y);
pango_cairo_show_layout(cr.get, layout.get);
}
cairo_surface_flush(surface.get);
composite(canvas, surface.get);
return canvas;
}
} // namespace effector