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:
@@ -0,0 +1,306 @@
|
||||
#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
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Writes a line (or several) over its input: a title, a caption, a watermark.
|
||||
// The family and the weight are chosen freely, and the text can sit on a box
|
||||
// filled with a colour or with a picture from disk. Like the blur and the
|
||||
// overlay it needs no source images of its own, so it can lead a chain; given
|
||||
// sources it fills the canvas from a randomly chosen one first.
|
||||
//
|
||||
// The glyphs come from Pango, so the exact pixels depend on the fonts the
|
||||
// machine has installed: a seed reproduces everything this effect draws, but a
|
||||
// machine without the chosen family renders the substitute its fontconfig
|
||||
// picks.
|
||||
class TextEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "text"; }
|
||||
std::string title() const override { return "Text"; }
|
||||
std::string description() const override {
|
||||
return "Writes text over its input in a chosen family, weight and size, optionally on a "
|
||||
"box filled with a colour or with a picture.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
|
||||
private:
|
||||
// The box picture is re-read on every param change otherwise, and a render
|
||||
// is a keystroke away in the interface. Renders can overlap, so it is
|
||||
// guarded.
|
||||
void loadBoxImage(const std::string& path, Image& image,
|
||||
std::vector<std::uint8_t>& alpha) const;
|
||||
|
||||
mutable std::mutex cacheMutex_;
|
||||
mutable std::string cachedPath_;
|
||||
mutable Image cached_;
|
||||
mutable std::vector<std::uint8_t> cachedAlpha_;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
Reference in New Issue
Block a user