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:
@@ -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
|
choosers from them and `src/cli/options.cpp` generates flags from them
|
||||||
(`strip_height` → `--strip-height`). Never add effect-specific branches to the
|
(`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
|
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
|
travel as `ParamValues` (a
|
||||||
`map<string, variant<long,double,bool,string>>`), read back with
|
`map<string, variant<long,double,bool,string>>`), read back with
|
||||||
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`.
|
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`.
|
||||||
@@ -96,7 +99,8 @@ is discarded rather than mixed into the new project (`dropLoadedImages_`).
|
|||||||
### Two link targets, deliberately
|
### Two link targets, deliberately
|
||||||
|
|
||||||
`effector_core` (static) holds image IO, params, effects, rng and links only
|
`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
|
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.
|
`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
|
`Rng` (`src/core/rng.hpp`) is a hand-written xoshiro256\*\* rather than
|
||||||
`<random>`, because libstdc++ distributions are not portable across
|
`<random>`, because libstdc++ distributions are not portable across
|
||||||
implementations. Same sources + settings + seed must produce byte-identical
|
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:
|
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,
|
the scanlines effect first draws the whole strip plan serially from the rng,
|
||||||
then copies pixels in parallel.
|
then copies pixels in parallel.
|
||||||
|
|||||||
+5
-1
@@ -15,6 +15,9 @@ set(CMAKE_CXX_FLAGS_RELEASE "-O3 -DNDEBUG")
|
|||||||
find_package(PkgConfig REQUIRED)
|
find_package(PkgConfig REQUIRED)
|
||||||
pkg_check_modules(GTKMM REQUIRED IMPORTED_TARGET gtkmm-4.0)
|
pkg_check_modules(GTKMM REQUIRED IMPORTED_TARGET gtkmm-4.0)
|
||||||
pkg_check_modules(GDKPIXBUF REQUIRED IMPORTED_TARGET gdk-pixbuf-2.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)
|
find_package(Threads REQUIRED)
|
||||||
|
|
||||||
@@ -32,11 +35,12 @@ add_library(effector_core STATIC
|
|||||||
src/effects/monochrome.cpp
|
src/effects/monochrome.cpp
|
||||||
src/effects/overlay.cpp
|
src/effects/overlay.cpp
|
||||||
src/effects/scanlines.cpp
|
src/effects/scanlines.cpp
|
||||||
|
src/effects/text.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(effector_core PUBLIC src)
|
target_include_directories(effector_core PUBLIC src)
|
||||||
# The core is GUI-free: it only needs gdk-pixbuf for decoding and encoding,
|
# The core is GUI-free: it only needs gdk-pixbuf for decoding and encoding,
|
||||||
# so headless rendering works without a display.
|
# 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)
|
target_compile_options(effector_core PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
|
||||||
# --- application --------------------------------------------------------------
|
# --- application --------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "cli/options.hpp"
|
#include "cli/options.hpp"
|
||||||
|
|
||||||
#include <charconv>
|
#include <charconv>
|
||||||
|
#include <cstdint>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
@@ -111,8 +112,19 @@ Options parseOptions(int argc, char** argv) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case ParamSpec::Type::Path:
|
case ParamSpec::Type::Path:
|
||||||
|
case ParamSpec::Type::Text:
|
||||||
options.params[spec->key] = requireValue(argc, argv, i, arg);
|
options.params[spec->key] = requireValue(argc, argv, i, arg);
|
||||||
break;
|
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: {
|
case ParamSpec::Type::Choice: {
|
||||||
const std::string text = requireValue(argc, argv, i, arg);
|
const std::string text = requireValue(argc, argv, i, arg);
|
||||||
bool valid = false;
|
bool valid = false;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ ParamValues Effect::defaults() const {
|
|||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Choice:
|
case ParamSpec::Type::Choice:
|
||||||
case ParamSpec::Type::Path:
|
case ParamSpec::Type::Path:
|
||||||
|
case ParamSpec::Type::Text:
|
||||||
|
case ParamSpec::Type::Color:
|
||||||
values[spec.key] = spec.defaultChoice;
|
values[spec.key] = spec.defaultChoice;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,28 @@ ParamSpec ParamSpec::makePath(std::string key, std::string label, std::string he
|
|||||||
return spec;
|
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 {
|
namespace {
|
||||||
|
|
||||||
const ParamValue* find(const ParamValues& values, const std::string& key) {
|
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;
|
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<std::uint8_t>(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
|
} // namespace effector
|
||||||
|
|||||||
+20
-1
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <variant>
|
#include <variant>
|
||||||
@@ -10,7 +11,7 @@ namespace effector {
|
|||||||
// Declarative description of one effect knob. The GUI turns these into widgets
|
// 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.
|
// and the CLI turns them into flags, so neither knows about any effect.
|
||||||
struct ParamSpec {
|
struct ParamSpec {
|
||||||
enum class Type { Int, Double, Bool, Choice, Path };
|
enum class Type { Int, Double, Bool, Choice, Path, Text, Color };
|
||||||
|
|
||||||
Type type = Type::Int;
|
Type type = Type::Int;
|
||||||
std::string key;
|
std::string key;
|
||||||
@@ -39,6 +40,14 @@ struct ParamSpec {
|
|||||||
// flag taking any string on the command line, written relative to the
|
// flag taking any string on the command line, written relative to the
|
||||||
// project folder so that a folder still opens after it is moved.
|
// project folder so that a folder still opens after it is moved.
|
||||||
static ParamSpec makePath(std::string key, std::string label, std::string help = {});
|
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<long, double, bool, std::string>;
|
using ParamValue = std::variant<long, double, bool, std::string>;
|
||||||
@@ -52,4 +61,14 @@ std::string paramChoice(const ParamValues& values, const std::string& key,
|
|||||||
std::string paramText(const ParamValues& values, const std::string& key,
|
std::string paramText(const ParamValues& values, const std::string& key,
|
||||||
const std::string& fallback);
|
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
|
} // namespace effector
|
||||||
|
|||||||
+21
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <charconv>
|
#include <charconv>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
@@ -358,8 +359,15 @@ std::string projectToJson(const Project& project, const std::string& directory)
|
|||||||
spec.digits);
|
spec.digits);
|
||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Choice:
|
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;
|
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: {
|
case ParamSpec::Type::Path: {
|
||||||
// Written relative to the project folder, exactly as the
|
// Written relative to the project folder, exactly as the
|
||||||
// sources are, so a folder still opens once it is moved.
|
// 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);
|
value->text.empty() ? value->text : resolve(value->text, directory);
|
||||||
}
|
}
|
||||||
break;
|
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:
|
case ParamSpec::Type::Choice:
|
||||||
for (const auto& choice : spec.choices) {
|
for (const auto& choice : spec.choices) {
|
||||||
if (value->kind == Json::Kind::String && value->text == choice) {
|
if (value->kind == Json::Kind::String && value->text == choice) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "effects/monochrome.hpp"
|
#include "effects/monochrome.hpp"
|
||||||
#include "effects/overlay.hpp"
|
#include "effects/overlay.hpp"
|
||||||
#include "effects/scanlines.hpp"
|
#include "effects/scanlines.hpp"
|
||||||
|
#include "effects/text.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ const std::vector<EffectPtr>& effects() {
|
|||||||
std::make_shared<GrainEffect>(),
|
std::make_shared<GrainEffect>(),
|
||||||
std::make_shared<MonochromeEffect>(),
|
std::make_shared<MonochromeEffect>(),
|
||||||
std::make_shared<OverlayEffect>(),
|
std::make_shared<OverlayEffect>(),
|
||||||
|
std::make_shared<TextEffect>(),
|
||||||
};
|
};
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
@@ -315,6 +317,34 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
widget = std::move(box);
|
widget = std::move(box);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case ParamSpec::Type::Text: {
|
||||||
|
auto entry = std::make_unique<Gtk::Entry>();
|
||||||
|
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::ColorDialogButton>(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: {
|
case ParamSpec::Type::Choice: {
|
||||||
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
|
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
|
||||||
auto dropdown = std::make_unique<Gtk::DropDown>();
|
auto dropdown = std::make_unique<Gtk::DropDown>();
|
||||||
@@ -358,9 +388,19 @@ ParamValues EffectorWindow::paramValues() const {
|
|||||||
values[entry.spec.key] = static_cast<Gtk::SpinButton*>(entry.widget)->get_value();
|
values[entry.spec.key] = static_cast<Gtk::SpinButton*>(entry.widget)->get_value();
|
||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Path:
|
case ParamSpec::Type::Path:
|
||||||
|
case ParamSpec::Type::Text:
|
||||||
values[entry.spec.key] =
|
values[entry.spec.key] =
|
||||||
std::string(static_cast<Gtk::Entry*>(entry.widget)->get_text());
|
std::string(static_cast<Gtk::Entry*>(entry.widget)->get_text());
|
||||||
break;
|
break;
|
||||||
|
case ParamSpec::Type::Color: {
|
||||||
|
const auto colour = static_cast<Gtk::ColorDialogButton*>(entry.widget)->get_rgba();
|
||||||
|
const std::uint8_t rgb[3] = {
|
||||||
|
static_cast<std::uint8_t>(std::lround(colour.get_red() * 255.0)),
|
||||||
|
static_cast<std::uint8_t>(std::lround(colour.get_green() * 255.0)),
|
||||||
|
static_cast<std::uint8_t>(std::lround(colour.get_blue() * 255.0))};
|
||||||
|
values[entry.spec.key] = formatColor(rgb);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case ParamSpec::Type::Choice: {
|
case ParamSpec::Type::Choice: {
|
||||||
const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected();
|
const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected();
|
||||||
if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index];
|
if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index];
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
#include "effects/monochrome.hpp"
|
#include "effects/monochrome.hpp"
|
||||||
#include "effects/overlay.hpp"
|
#include "effects/overlay.hpp"
|
||||||
#include "effects/scanlines.hpp"
|
#include "effects/scanlines.hpp"
|
||||||
|
#include "effects/text.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -129,6 +130,15 @@ effector::Image renderOverlay(const std::vector<effector::Image>& sources, int w
|
|||||||
return effect.render(sources, width, height, rng, merged);
|
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.
|
// 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) {
|
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;
|
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::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::cout << "chain\n";
|
||||||
{
|
{
|
||||||
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
||||||
|
|||||||
Reference in New Issue
Block a user