#include "core/project.hpp" #include #include #include #include #include #include #include #include #include #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 items; std::vector> 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::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( 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(code)); } else if (code < 0x800) { out.push_back(static_cast(0xC0 | (code >> 6))); out.push_back(static_cast(0x80 | (code & 0x3F))); } else { out.push_back(static_cast(0xE0 | (code >> 12))); out.push_back(static_cast(0x80 | ((code >> 6) & 0x3F))); out.push_back(static_cast(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(c) < 0x20) { char buffer[7]; std::snprintf(buffer, sizeof(buffer), "\\u%04x", static_cast(static_cast(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(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(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(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