diff --git a/CLAUDE.md b/CLAUDE.md index e896136..04c7789 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,10 @@ about an effect: `src/gui/window.cpp` builds SpinButtons/Switches/DropDowns/file choosers from them and `src/cli/options.cpp` generates flags from them (`strip_height` → `--strip-height`). Never add effect-specific branches to the GUI or the parser — add a `ParamSpec` instead. A `Path` param names a file on -disk; like a source path it is written relative to the project folder. Values +disk; like a source path it is written relative to the project folder. A `Text` +param is free-form text the user types, and a `Color` param is a colour written +`#rrggbb` (`parseColor`/`formatColor`/`paramColor` in `src/core/params.hpp`), +shown as a colour button in the interface. Values travel as `ParamValues` (a `map>`), read back with `paramInt`/`paramDouble`/`paramBool`/`paramChoice`. @@ -96,7 +99,8 @@ is discarded rather than mixed into the new project (`dropLoadedImages_`). ### Two link targets, deliberately `effector_core` (static) holds image IO, params, effects, rng and links only -`gdk-pixbuf-2.0` + Threads — **no gtkmm, no display**. The `effector` executable +`gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need +no display) + Threads — **no gtkmm, no display**. The `effector` executable adds `src/main.cpp`, `src/cli/`, `src/gui/` and links gtkmm. Keep GTK out of `src/core/` and `src/effects/`; the tests link the core alone and rely on this. @@ -105,7 +109,10 @@ adds `src/main.cpp`, `src/cli/`, `src/gui/` and links gtkmm. Keep GTK out of `Rng` (`src/core/rng.hpp`) is a hand-written xoshiro256\*\* rather than ``, because libstdc++ distributions are not portable across implementations. Same sources + settings + seed must produce byte-identical -output on any machine — the tests assert this. Effects must draw *every* random +output on any machine — the tests assert this. The one thing outside this is the +text effect's glyphs, which are whatever shapes the machine's fonts and +fontconfig give: everything it is handed, the rng included, is still used the +same way. Effects must draw *every* random value from the `Rng&` they are handed, and must not depend on thread scheduling: the scanlines effect first draws the whole strip plan serially from the rng, then copies pixels in parallel. diff --git a/CMakeLists.txt b/CMakeLists.txt index 9c3f44e..19a1b12 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,9 @@ set(CMAKE_CXX_FLAGS_RELEASE "-O3 -DNDEBUG") find_package(PkgConfig REQUIRED) pkg_check_modules(GTKMM REQUIRED IMPORTED_TARGET gtkmm-4.0) pkg_check_modules(GDKPIXBUF REQUIRED IMPORTED_TARGET gdk-pixbuf-2.0) +# Pango and Cairo draw the text effect's glyphs. Neither needs a display, so +# the core stays headless. +pkg_check_modules(PANGOCAIRO REQUIRED IMPORTED_TARGET pangocairo) find_package(Threads REQUIRED) @@ -32,11 +35,12 @@ add_library(effector_core STATIC src/effects/monochrome.cpp src/effects/overlay.cpp src/effects/scanlines.cpp + src/effects/text.cpp ) target_include_directories(effector_core PUBLIC src) # The core is GUI-free: it only needs gdk-pixbuf for decoding and encoding, # so headless rendering works without a display. -target_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF Threads::Threads) +target_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF PkgConfig::PANGOCAIRO Threads::Threads) target_compile_options(effector_core PRIVATE -Wall -Wextra -Wpedantic) # --- application -------------------------------------------------------------- diff --git a/src/cli/options.cpp b/src/cli/options.cpp index 1f61745..307ac90 100644 --- a/src/cli/options.cpp +++ b/src/cli/options.cpp @@ -1,6 +1,7 @@ #include "cli/options.hpp" #include +#include #include #include @@ -111,8 +112,19 @@ Options parseOptions(int argc, char** argv) { break; } case ParamSpec::Type::Path: + case ParamSpec::Type::Text: options.params[spec->key] = requireValue(argc, argv, i, arg); break; + case ParamSpec::Type::Color: { + const std::string text = requireValue(argc, argv, i, arg); + std::uint8_t rgb[3]; + if (!parseColor(text, rgb)) { + throw EffectError(arg + ": '" + text + + "' is not a colour, expected e.g. #ff8800"); + } + options.params[spec->key] = formatColor(rgb); + break; + } case ParamSpec::Type::Choice: { const std::string text = requireValue(argc, argv, i, arg); bool valid = false; diff --git a/src/core/effect.cpp b/src/core/effect.cpp index bebdda6..d6590a0 100644 --- a/src/core/effect.cpp +++ b/src/core/effect.cpp @@ -17,6 +17,8 @@ ParamValues Effect::defaults() const { break; case ParamSpec::Type::Choice: case ParamSpec::Type::Path: + case ParamSpec::Type::Text: + case ParamSpec::Type::Color: values[spec.key] = spec.defaultChoice; break; } diff --git a/src/core/params.cpp b/src/core/params.cpp index 10e6910..76e2f19 100644 --- a/src/core/params.cpp +++ b/src/core/params.cpp @@ -72,6 +72,28 @@ ParamSpec ParamSpec::makePath(std::string key, std::string label, std::string he return spec; } +ParamSpec ParamSpec::makeText(std::string key, std::string label, std::string defaultText, + std::string help) { + ParamSpec spec; + spec.type = Type::Text; + spec.key = std::move(key); + spec.label = std::move(label); + spec.help = std::move(help); + spec.defaultChoice = std::move(defaultText); + return spec; +} + +ParamSpec ParamSpec::makeColor(std::string key, std::string label, std::string defaultColor, + std::string help) { + ParamSpec spec; + spec.type = Type::Color; + spec.key = std::move(key); + spec.label = std::move(label); + spec.help = std::move(help); + spec.defaultChoice = std::move(defaultColor); + return spec; +} + namespace { const ParamValue* find(const ParamValues& values, const std::string& key) { @@ -119,4 +141,50 @@ std::string paramText(const ParamValues& values, const std::string& key, return fallback; } +namespace { + +int hexDigit(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +} // namespace + +bool parseColor(const std::string& text, std::uint8_t rgb[3]) { + std::string digits = text; + if (!digits.empty() && digits.front() == '#') digits.erase(digits.begin()); + if (digits.size() != 3 && digits.size() != 6) return false; + + int parsed[6]; + for (std::size_t i = 0; i < digits.size(); ++i) { + parsed[i] = hexDigit(digits[i]); + if (parsed[i] < 0) return false; + } + for (int c = 0; c < 3; ++c) { + // "#abc" is the usual shorthand for "#aabbcc". + const int value = digits.size() == 3 ? parsed[c] * 17 : parsed[c * 2] * 16 + parsed[c * 2 + 1]; + rgb[c] = static_cast(value); + } + return true; +} + +std::string formatColor(const std::uint8_t rgb[3]) { + static const char* const kDigits = "0123456789abcdef"; + std::string text = "#"; + for (int c = 0; c < 3; ++c) { + text += kDigits[rgb[c] >> 4]; + text += kDigits[rgb[c] & 0x0F]; + } + return text; +} + +void paramColor(const ParamValues& values, const std::string& key, const std::string& fallback, + std::uint8_t rgb[3]) { + if (parseColor(paramText(values, key, fallback), rgb)) return; + if (parseColor(fallback, rgb)) return; + rgb[0] = rgb[1] = rgb[2] = 0; +} + } // namespace effector diff --git a/src/core/params.hpp b/src/core/params.hpp index b9d03d3..eba8421 100644 --- a/src/core/params.hpp +++ b/src/core/params.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -10,7 +11,7 @@ namespace effector { // Declarative description of one effect knob. The GUI turns these into widgets // and the CLI turns them into flags, so neither knows about any effect. struct ParamSpec { - enum class Type { Int, Double, Bool, Choice, Path }; + enum class Type { Int, Double, Bool, Choice, Path, Text, Color }; Type type = Type::Int; std::string key; @@ -39,6 +40,14 @@ struct ParamSpec { // flag taking any string on the command line, written relative to the // project folder so that a folder still opens after it is moved. static ParamSpec makePath(std::string key, std::string label, std::string help = {}); + // Free-form text the user types: an entry in the interface, a flag taking + // any string on the command line. + static ParamSpec makeText(std::string key, std::string label, std::string defaultText, + std::string help = {}); + // A colour written "#rrggbb": a colour button in the interface, the same + // spelling as a flag value. + static ParamSpec makeColor(std::string key, std::string label, std::string defaultColor, + std::string help = {}); }; using ParamValue = std::variant; @@ -52,4 +61,14 @@ std::string paramChoice(const ParamValues& values, const std::string& key, std::string paramText(const ParamValues& values, const std::string& key, const std::string& fallback); +// Reads "#rgb" or "#rrggbb" (the leading hash optional) into three bytes. +// Returns false and leaves `rgb` untouched for anything else, so a malformed +// value falls back to the default rather than painting an arbitrary colour. +bool parseColor(const std::string& text, std::uint8_t rgb[3]); +std::string formatColor(const std::uint8_t rgb[3]); + +// The colour of a param, or `fallback` when the value does not parse. +void paramColor(const ParamValues& values, const std::string& key, const std::string& fallback, + std::uint8_t rgb[3]); + } // namespace effector diff --git a/src/core/project.cpp b/src/core/project.cpp index cc9b041..bfc06d7 100644 --- a/src/core/project.cpp +++ b/src/core/project.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -358,8 +359,15 @@ std::string projectToJson(const Project& project, const std::string& directory) spec.digits); break; case ParamSpec::Type::Choice: - out << quote(paramChoice(stage.values, spec.key, spec.defaultChoice)); + case ParamSpec::Type::Text: + out << quote(paramText(stage.values, spec.key, spec.defaultChoice)); break; + case ParamSpec::Type::Color: { + std::uint8_t rgb[3]; + paramColor(stage.values, spec.key, spec.defaultChoice, rgb); + out << quote(formatColor(rgb)); + break; + } case ParamSpec::Type::Path: { // Written relative to the project folder, exactly as the // sources are, so a folder still opens once it is moved. @@ -452,6 +460,18 @@ Project projectFromJson(const std::string& text, const std::string& directory) { value->text.empty() ? value->text : resolve(value->text, directory); } break; + case ParamSpec::Type::Text: + if (value->kind == Json::Kind::String) stage.values[spec.key] = value->text; + break; + case ParamSpec::Type::Color: { + // A colour that does not parse keeps the default, + // exactly as an unknown choice does. + std::uint8_t rgb[3]; + if (value->kind == Json::Kind::String && parseColor(value->text, rgb)) { + stage.values[spec.key] = formatColor(rgb); + } + break; + } case ParamSpec::Type::Choice: for (const auto& choice : spec.choices) { if (value->kind == Json::Kind::String && value->text == choice) { diff --git a/src/core/registry.cpp b/src/core/registry.cpp index 25e0df5..31fe57d 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -5,6 +5,7 @@ #include "effects/monochrome.hpp" #include "effects/overlay.hpp" #include "effects/scanlines.hpp" +#include "effects/text.hpp" namespace effector { @@ -16,6 +17,7 @@ const std::vector& effects() { std::make_shared(), std::make_shared(), std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/text.cpp b/src/effects/text.cpp new file mode 100644 index 0000000..fe772b7 --- /dev/null +++ b/src/effects/text.cpp @@ -0,0 +1,306 @@ +#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\", ..."), + 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& 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 diff --git a/src/effects/text.hpp b/src/effects/text.hpp new file mode 100644 index 0000000..1d5cc26 --- /dev/null +++ b/src/effects/text.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include + +#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& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& 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& alpha) const; + + mutable std::mutex cacheMutex_; + mutable std::string cachedPath_; + mutable Image cached_; + mutable std::vector cachedAlpha_; +}; + +} // namespace effector diff --git a/src/gui/window.cpp b/src/gui/window.cpp index 87656a9..377487f 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -315,6 +317,34 @@ void EffectorWindow::buildParamWidgets() { widget = std::move(box); break; } + case ParamSpec::Type::Text: { + auto entry = std::make_unique(); + entry->set_text(paramText(stage.values, spec.key, spec.defaultChoice)); + // Re-rendering on every keystroke would start a render per + // letter typed, so the text is taken when the entry is left or + // Enter is pressed. + entry->signal_activate().connect( + sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); + auto focus = Gtk::EventControllerFocus::create(); + focus->signal_leave().connect( + sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); + entry->add_controller(focus); + widget = std::move(entry); + break; + } + case ParamSpec::Type::Color: { + auto button = std::make_unique(Gtk::ColorDialog::create()); + button->set_halign(Gtk::Align::START); + std::uint8_t rgb[3]; + paramColor(stage.values, spec.key, spec.defaultChoice, rgb); + Gdk::RGBA colour; + colour.set_rgba(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0); + button->set_rgba(colour); + button->property_rgba().signal_changed().connect( + sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); + widget = std::move(button); + break; + } case ParamSpec::Type::Choice: { std::vector choices(spec.choices.begin(), spec.choices.end()); auto dropdown = std::make_unique(); @@ -358,9 +388,19 @@ ParamValues EffectorWindow::paramValues() const { values[entry.spec.key] = static_cast(entry.widget)->get_value(); break; case ParamSpec::Type::Path: + case ParamSpec::Type::Text: values[entry.spec.key] = std::string(static_cast(entry.widget)->get_text()); break; + case ParamSpec::Type::Color: { + const auto colour = static_cast(entry.widget)->get_rgba(); + const std::uint8_t rgb[3] = { + static_cast(std::lround(colour.get_red() * 255.0)), + static_cast(std::lround(colour.get_green() * 255.0)), + static_cast(std::lround(colour.get_blue() * 255.0))}; + values[entry.spec.key] = formatColor(rgb); + break; + } case ParamSpec::Type::Choice: { const auto index = static_cast(entry.widget)->get_selected(); if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index]; diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 951f4ff..013a6cf 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -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& sources, int w return effect.render(sources, width, height, rng, merged); } +effector::Image renderText(const std::vector& 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 pixel(const effector::Image& image, int x, int y) { const auto* px = image.row(y) + static_cast(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(argv)); + } catch (const EffectError&) { + threw = true; + } + check(threw, "an unparseable colour on the command line is reported"); + } + + std::cout << "text\n"; + { + const std::vector 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 sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};