#include "effects/text.hpp" #include #include #include #include #include "core/parallel.hpp" #include "effects/source_fill.hpp" namespace effector { const std::vector& TextEffect::params() const { static const std::vector 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\", ...") .styled(), 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.") .styled(), ParamSpec::makeBool("font_italic", "Italic", false, "Slants the glyphs.").styled(), ParamSpec::makeDouble("font_size", "Size", 96.0, 4.0, 2000.0, "Height of the glyphs in pixels.", 1.0, 1) .styled(), ParamSpec::makeColor("text_color", "Colour", "#ffffff", "The colour of the glyphs.").styled(), ParamSpec::makeChoice("text_align", "Alignment", "center", {"left", "center", "right"}, "How several lines line up against each other.") .styled(), 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.") .styled(), ParamSpec::makeColor("box_color", "Box colour", "#000000", "The colour the box is filled with.") .styled(), 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.") .styled(), ParamSpec::makeDouble("box_opacity", "Box opacity", 1.0, 0.0, 1.0, "How strongly the box is mixed over the input.") .styled(), 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.") .styled(), }; 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& 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(image.width()) * static_cast(image.height()); for (int y = 0; y < image.height(); ++y) { const std::uint8_t* in = image.row(y); auto* out = reinterpret_cast(data + static_cast(y) * stride); for (int x = 0; x < image.width(); ++x) { const std::uint32_t a = hasAlpha ? alpha[static_cast(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(surface)); const unsigned char* data = cairo_image_surface_get_data(const_cast(surface)); const int width = canvas.width(); parallelFor(static_cast(canvas.height()), [&](std::size_t begin, std::size_t end) { for (std::size_t y = begin; y < end; ++y) { const auto* in = reinterpret_cast(data + static_cast(y) * stride); std::uint8_t* out = canvas.row(static_cast(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(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::min(value, 255)); } } } }); } } // namespace void TextEffect::loadBoxImage(const std::string& path, Image& image, std::vector& alpha) const { std::lock_guard lock(cacheMutex_); if (cachedPath_ != path || cached_.empty()) { std::vector 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& 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(rng.inRange(0, std::max(0, src.width() - width))); const int sy = static_cast(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(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(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(paramInt(values, "text_offset_x", 0)); const double boxY = (centre ? (height - boxHeight) / 2.0 : 0.0) + static_cast(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 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(boxWidth) / picture.width(), static_cast(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