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>
512 lines
19 KiB
C++
512 lines
19 KiB
C++
#include "core/project.hpp"
|
|
|
|
#include <charconv>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <locale>
|
|
#include <sstream>
|
|
|
|
#include "core/effect.hpp"
|
|
#include "core/registry.hpp"
|
|
|
|
namespace effector {
|
|
namespace {
|
|
|
|
// A project file is small and its shape is fixed, so it carries its own
|
|
// reader rather than a JSON dependency: enough of the grammar to read back
|
|
// what projectToJson writes, and to give a line number when a hand-edited
|
|
// file is wrong.
|
|
struct Json {
|
|
enum class Kind { Null, Bool, Number, String, Array, Object };
|
|
|
|
Kind kind = Kind::Null;
|
|
bool boolean = false;
|
|
double number = 0.0;
|
|
bool integral = true; // written without a fraction or exponent
|
|
std::string text; // the string, or the digits a number was written as
|
|
std::vector<Json> items;
|
|
std::vector<std::pair<std::string, Json>> members;
|
|
|
|
const Json* member(const std::string& key) const {
|
|
for (const auto& entry : members) {
|
|
if (entry.first == key) return &entry.second;
|
|
}
|
|
return nullptr;
|
|
}
|
|
};
|
|
|
|
class JsonParser {
|
|
public:
|
|
explicit JsonParser(const std::string& text) : text_(text) {}
|
|
|
|
Json parse() {
|
|
skipSpace();
|
|
Json value = parseValue();
|
|
skipSpace();
|
|
if (pos_ != text_.size()) fail("trailing text after the end of the document");
|
|
return value;
|
|
}
|
|
|
|
private:
|
|
[[noreturn]] void fail(const std::string& what) const {
|
|
int line = 1;
|
|
for (std::size_t i = 0; i < pos_ && i < text_.size(); ++i) {
|
|
if (text_[i] == '\n') ++line;
|
|
}
|
|
throw EffectError("line " + std::to_string(line) + ": " + what);
|
|
}
|
|
|
|
char peek() const { return pos_ < text_.size() ? text_[pos_] : '\0'; }
|
|
|
|
void skipSpace() {
|
|
while (pos_ < text_.size()) {
|
|
const char c = text_[pos_];
|
|
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
|
++pos_;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
void expect(char c) {
|
|
if (peek() != c) fail(std::string("expected '") + c + "'");
|
|
++pos_;
|
|
}
|
|
|
|
bool literal(const char* word) {
|
|
const std::size_t length = std::char_traits<char>::length(word);
|
|
if (text_.compare(pos_, length, word) != 0) return false;
|
|
pos_ += length;
|
|
return true;
|
|
}
|
|
|
|
Json parseValue() {
|
|
switch (peek()) {
|
|
case '{': return parseObject();
|
|
case '[': return parseArray();
|
|
case '"': {
|
|
Json value;
|
|
value.kind = Json::Kind::String;
|
|
value.text = parseString();
|
|
return value;
|
|
}
|
|
default: break;
|
|
}
|
|
if (literal("true")) return boolean(true);
|
|
if (literal("false")) return boolean(false);
|
|
if (literal("null")) return Json();
|
|
return parseNumber();
|
|
}
|
|
|
|
static Json boolean(bool state) {
|
|
Json value;
|
|
value.kind = Json::Kind::Bool;
|
|
value.boolean = state;
|
|
return value;
|
|
}
|
|
|
|
Json parseObject() {
|
|
Json value;
|
|
value.kind = Json::Kind::Object;
|
|
expect('{');
|
|
skipSpace();
|
|
if (peek() == '}') {
|
|
++pos_;
|
|
return value;
|
|
}
|
|
while (true) {
|
|
skipSpace();
|
|
std::string key = parseString();
|
|
skipSpace();
|
|
expect(':');
|
|
skipSpace();
|
|
value.members.emplace_back(std::move(key), parseValue());
|
|
skipSpace();
|
|
if (peek() == ',') {
|
|
++pos_;
|
|
continue;
|
|
}
|
|
expect('}');
|
|
return value;
|
|
}
|
|
}
|
|
|
|
Json parseArray() {
|
|
Json value;
|
|
value.kind = Json::Kind::Array;
|
|
expect('[');
|
|
skipSpace();
|
|
if (peek() == ']') {
|
|
++pos_;
|
|
return value;
|
|
}
|
|
while (true) {
|
|
skipSpace();
|
|
value.items.push_back(parseValue());
|
|
skipSpace();
|
|
if (peek() == ',') {
|
|
++pos_;
|
|
continue;
|
|
}
|
|
expect(']');
|
|
return value;
|
|
}
|
|
}
|
|
|
|
std::string parseString() {
|
|
expect('"');
|
|
std::string out;
|
|
while (true) {
|
|
if (pos_ >= text_.size()) fail("unterminated string");
|
|
const char c = text_[pos_++];
|
|
if (c == '"') return out;
|
|
if (c != '\\') {
|
|
out.push_back(c);
|
|
continue;
|
|
}
|
|
if (pos_ >= text_.size()) fail("unterminated escape");
|
|
switch (text_[pos_++]) {
|
|
case '"': out.push_back('"'); break;
|
|
case '\\': out.push_back('\\'); break;
|
|
case '/': out.push_back('/'); break;
|
|
case 'b': out.push_back('\b'); break;
|
|
case 'f': out.push_back('\f'); break;
|
|
case 'n': out.push_back('\n'); break;
|
|
case 'r': out.push_back('\r'); break;
|
|
case 't': out.push_back('\t'); break;
|
|
case 'u': {
|
|
// Only the basic plane, which is all a path or a choice
|
|
// name can need; surrogate pairs are passed through as is.
|
|
if (pos_ + 4 > text_.size()) fail("short \\u escape");
|
|
const unsigned code = static_cast<unsigned>(
|
|
std::strtoul(text_.substr(pos_, 4).c_str(), nullptr, 16));
|
|
pos_ += 4;
|
|
appendUtf8(out, code);
|
|
break;
|
|
}
|
|
default: fail("unknown escape");
|
|
}
|
|
}
|
|
}
|
|
|
|
static void appendUtf8(std::string& out, unsigned code) {
|
|
if (code < 0x80) {
|
|
out.push_back(static_cast<char>(code));
|
|
} else if (code < 0x800) {
|
|
out.push_back(static_cast<char>(0xC0 | (code >> 6)));
|
|
out.push_back(static_cast<char>(0x80 | (code & 0x3F)));
|
|
} else {
|
|
out.push_back(static_cast<char>(0xE0 | (code >> 12)));
|
|
out.push_back(static_cast<char>(0x80 | ((code >> 6) & 0x3F)));
|
|
out.push_back(static_cast<char>(0x80 | (code & 0x3F)));
|
|
}
|
|
}
|
|
|
|
Json parseNumber() {
|
|
const std::size_t start = pos_;
|
|
if (peek() == '-' || peek() == '+') ++pos_;
|
|
bool integral = true;
|
|
while (pos_ < text_.size()) {
|
|
const char c = text_[pos_];
|
|
if (c >= '0' && c <= '9') {
|
|
++pos_;
|
|
} else if (c == '.' || c == 'e' || c == 'E') {
|
|
integral = false;
|
|
++pos_;
|
|
} else if (c == '-' || c == '+') { // an exponent's sign
|
|
++pos_;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
if (pos_ == start) fail("expected a value");
|
|
|
|
Json value;
|
|
value.kind = Json::Kind::Number;
|
|
value.integral = integral;
|
|
// from_chars rather than stod: a number in JSON always has a point for
|
|
// a decimal mark, whatever LC_NUMERIC says the user's decimal mark is.
|
|
const char* const first = text_.data() + start + (text_[start] == '+' ? 1 : 0);
|
|
const char* const last = text_.data() + pos_;
|
|
const auto parsed = std::from_chars(first, last, value.number);
|
|
if (parsed.ec != std::errc() || parsed.ptr != last) {
|
|
fail("'" + text_.substr(start, pos_ - start) + "' is not a number");
|
|
}
|
|
value.text.assign(first, last); // kept whole for the seed, see below
|
|
return value;
|
|
}
|
|
|
|
const std::string& text_;
|
|
std::size_t pos_ = 0;
|
|
};
|
|
|
|
std::string quote(const std::string& text) {
|
|
std::string out = "\"";
|
|
for (const char c : text) {
|
|
switch (c) {
|
|
case '"': out += "\\\""; break;
|
|
case '\\': out += "\\\\"; break;
|
|
case '\n': out += "\\n"; break;
|
|
case '\r': out += "\\r"; break;
|
|
case '\t': out += "\\t"; break;
|
|
default:
|
|
if (static_cast<unsigned char>(c) < 0x20) {
|
|
char buffer[7];
|
|
std::snprintf(buffer, sizeof(buffer), "\\u%04x",
|
|
static_cast<unsigned>(static_cast<unsigned char>(c)));
|
|
out += buffer;
|
|
} else {
|
|
out.push_back(c);
|
|
}
|
|
}
|
|
}
|
|
return out + "\"";
|
|
}
|
|
|
|
std::string number(double value, int digits) {
|
|
std::ostringstream out;
|
|
out.imbue(std::locale::classic());
|
|
out.setf(std::ios::fixed);
|
|
out.precision(digits);
|
|
out << value;
|
|
std::string text = out.str();
|
|
// 5.00 reads better as 5.0 and 0.250 as 0.25.
|
|
while (text.size() > 1 && text.back() == '0' && text[text.size() - 2] != '.') text.pop_back();
|
|
return text;
|
|
}
|
|
|
|
// Relative when the image lives under the project folder, absolute otherwise:
|
|
// a folder holding the project and its images stays movable.
|
|
std::string relativeTo(const std::string& path, const std::string& directory) {
|
|
if (directory.empty()) return path;
|
|
std::error_code error;
|
|
const auto relative = std::filesystem::relative(path, directory, error);
|
|
if (error || relative.empty()) return path;
|
|
const auto text = relative.generic_string();
|
|
return text.rfind("..", 0) == 0 ? path : text;
|
|
}
|
|
|
|
// A canvas side is checked as a double, before it is narrowed: casting a
|
|
// number outside the range of an int is undefined, and even where it wraps
|
|
// quietly it turns a size the file never named into one that looks ordinary.
|
|
int canvasSide(const Json& value, const char* what) {
|
|
if (value.kind != Json::Kind::Number || value.number != std::trunc(value.number)) {
|
|
throw EffectError(std::string("the canvas ") + what + " must be a whole number");
|
|
}
|
|
if (!(value.number >= kMinCanvasSide) || !(value.number <= kMaxCanvasSide)) {
|
|
throw EffectError(std::string("the canvas ") + what + " must be between " +
|
|
std::to_string(kMinCanvasSide) + " and " +
|
|
std::to_string(kMaxCanvasSide) + " pixels");
|
|
}
|
|
return static_cast<int>(value.number);
|
|
}
|
|
|
|
std::string resolve(const std::string& path, const std::string& directory) {
|
|
std::filesystem::path file(path);
|
|
if (file.is_absolute() || directory.empty()) return path;
|
|
return (std::filesystem::path(directory) / file).lexically_normal().string();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::string projectToJson(const Project& project, const std::string& directory) {
|
|
// JSON is written in the C locale whatever the user's is: a decimal comma
|
|
// or a digit grouping would make the file something this reader — or any
|
|
// other — cannot read back. gtkmm sets the global locale from the
|
|
// environment, so a stream left to its default is the user's locale.
|
|
std::ostringstream out;
|
|
out.imbue(std::locale::classic());
|
|
out << "{\n";
|
|
out << " \"effector_project\": 1,\n";
|
|
out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height
|
|
<< "},\n";
|
|
if (project.seed) out << " \"seed\": " << *project.seed << ",\n";
|
|
|
|
out << " \"sources\": [";
|
|
for (std::size_t i = 0; i < project.sources.size(); ++i) {
|
|
out << (i == 0 ? "\n" : ",\n") << " " << quote(relativeTo(project.sources[i], directory));
|
|
}
|
|
out << (project.sources.empty() ? "" : "\n ") << "],\n";
|
|
|
|
out << " \"chain\": [";
|
|
for (std::size_t i = 0; i < project.stages.size(); ++i) {
|
|
const auto& stage = project.stages[i];
|
|
if (!stage.effect) continue;
|
|
out << (i == 0 ? "\n" : ",\n");
|
|
out << " {\n \"effect\": " << quote(stage.effect->name()) << ",\n";
|
|
out << " \"params\": {";
|
|
|
|
const auto& specs = stage.effect->params();
|
|
bool first = true;
|
|
for (const auto& spec : specs) {
|
|
out << (first ? "\n" : ",\n") << " " << quote(spec.key) << ": ";
|
|
first = false;
|
|
switch (spec.type) {
|
|
case ParamSpec::Type::Bool:
|
|
out << (paramBool(stage.values, spec.key, spec.defaultValue != 0.0) ? "true"
|
|
: "false");
|
|
break;
|
|
case ParamSpec::Type::Int:
|
|
out << paramInt(stage.values, spec.key, static_cast<long>(spec.defaultValue));
|
|
break;
|
|
case ParamSpec::Type::Double:
|
|
out << number(paramDouble(stage.values, spec.key, spec.defaultValue),
|
|
spec.digits);
|
|
break;
|
|
case ParamSpec::Type::Choice:
|
|
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.
|
|
const auto path = paramText(stage.values, spec.key, "");
|
|
out << quote(path.empty() ? path : relativeTo(path, directory));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
out << (specs.empty() ? "}" : "\n }") << "\n }";
|
|
}
|
|
out << (project.stages.empty() ? "" : "\n ") << "]\n";
|
|
out << "}\n";
|
|
return out.str();
|
|
}
|
|
|
|
Project projectFromJson(const std::string& text, const std::string& directory) {
|
|
JsonParser parser(text);
|
|
const Json root = parser.parse();
|
|
if (root.kind != Json::Kind::Object) throw EffectError("a project must be a JSON object");
|
|
|
|
Project project;
|
|
|
|
if (const auto* canvas = root.member("canvas")) {
|
|
if (const auto* width = canvas->member("width")) project.width = canvasSide(*width, "width");
|
|
if (const auto* height = canvas->member("height")) {
|
|
project.height = canvasSide(*height, "height");
|
|
}
|
|
}
|
|
|
|
if (const auto* seed = root.member("seed")) {
|
|
// Taken from the digits rather than from the double: a 64-bit seed has
|
|
// more bits than a double has mantissa, and a seed that comes back off
|
|
// by a few is a project that no longer renders its own picture.
|
|
std::uint64_t value = 0;
|
|
const char* const first = seed->text.data();
|
|
const char* const last = first + seed->text.size();
|
|
if (seed->kind == Json::Kind::Number && seed->integral &&
|
|
std::from_chars(first, last, value).ptr == last) {
|
|
project.seed = value;
|
|
} else if (seed->kind == Json::Kind::Number) {
|
|
throw EffectError("the seed must be a whole number that fits in 64 bits");
|
|
}
|
|
}
|
|
|
|
if (const auto* sources = root.member("sources")) {
|
|
for (const auto& item : sources->items) {
|
|
if (item.kind == Json::Kind::String) {
|
|
project.sources.push_back(resolve(item.text, directory));
|
|
}
|
|
}
|
|
}
|
|
|
|
const auto* chain = root.member("chain");
|
|
if (chain == nullptr || chain->kind != Json::Kind::Array || chain->items.empty()) {
|
|
throw EffectError("the project has no effect chain");
|
|
}
|
|
for (const auto& item : chain->items) {
|
|
const auto* name = item.member("effect");
|
|
if (name == nullptr || name->kind != Json::Kind::String) {
|
|
throw EffectError("a chain stage has no effect name");
|
|
}
|
|
|
|
ChainStage stage;
|
|
stage.effect = effectByName(name->text); // throws on an unknown effect
|
|
stage.values = stage.effect->defaults();
|
|
|
|
// Driven by the specs, not by the file: a key the effect does not
|
|
// declare is dropped, and an old project simply keeps the defaults for
|
|
// params that did not exist when it was written.
|
|
if (const auto* params = item.member("params")) {
|
|
for (const auto& spec : stage.effect->params()) {
|
|
const auto* value = params->member(spec.key);
|
|
if (value == nullptr) continue;
|
|
switch (spec.type) {
|
|
case ParamSpec::Type::Bool:
|
|
if (value->kind == Json::Kind::Bool) stage.values[spec.key] = value->boolean;
|
|
break;
|
|
case ParamSpec::Type::Int:
|
|
if (value->kind == Json::Kind::Number) {
|
|
stage.values[spec.key] = static_cast<long>(std::llround(value->number));
|
|
}
|
|
break;
|
|
case ParamSpec::Type::Double:
|
|
if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number;
|
|
break;
|
|
case ParamSpec::Type::Path:
|
|
if (value->kind == Json::Kind::String) {
|
|
stage.values[spec.key] =
|
|
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) {
|
|
stage.values[spec.key] = choice;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
project.stages.push_back(std::move(stage));
|
|
}
|
|
|
|
return project;
|
|
}
|
|
|
|
void saveProject(const std::string& path, const Project& project) {
|
|
const auto directory = std::filesystem::path(path).parent_path().string();
|
|
std::ofstream file(path, std::ios::binary | std::ios::trunc);
|
|
if (!file) throw EffectError("cannot write " + path);
|
|
file << projectToJson(project, directory);
|
|
if (!file) throw EffectError("could not write " + path);
|
|
}
|
|
|
|
Project loadProject(const std::string& path) {
|
|
std::ifstream file(path, std::ios::binary);
|
|
if (!file) throw EffectError("cannot read " + path);
|
|
std::ostringstream buffer;
|
|
buffer << file.rdbuf();
|
|
try {
|
|
return projectFromJson(buffer.str(), std::filesystem::path(path).parent_path().string());
|
|
} catch (const EffectError& error) {
|
|
throw EffectError(std::filesystem::path(path).filename().string() + ": " + error.what());
|
|
}
|
|
}
|
|
|
|
} // namespace effector
|