diff --git a/CLAUDE.md b/CLAUDE.md index 2e812cd..ab47bf4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,17 @@ CLI builds it from repeated `-e`: `-e scanlines --strip-height 6 -e blur (`src/cli/options.cpp`) hands each flag to the stage whose effect declares it — two effects must not declare the same param key. +### The canvas is a format or plain pixels + +`src/core/canvas.cpp` holds the one table of standard sizes both front-ends +offer: web formats are a fixed pixel size, print formats a sheet in millimetres +that becomes pixels at a dpi. Adding a format is a line in `canvasFormats()` — +the dropdown, `--format` and `--help` all walk that table. The pixels stay the +truth everywhere (`renderChain` never hears about formats); a format is the +*presentation* of a canvas, carried alongside the pixels in `Options` and +`Project` and empty for a custom size. Where a format is named it decides the +pixels, so a project saved as A4 reopens as A4. + ### Projects are plain JSON `src/core/project.cpp` reads and writes the whole setup — source paths, the diff --git a/CMakeLists.txt b/CMakeLists.txt index 72fc0bd..5f6e3c5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,7 @@ find_package(Threads REQUIRED) # --- core: effects and image handling, no GUI --------------------------------- add_library(effector_core STATIC + src/core/canvas.cpp src/core/chain.cpp src/core/effect.cpp src/core/image.cpp diff --git a/README.md b/README.md index 640bd59..8b22ab4 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,22 @@ build/effector -o out.png -s 1920x1080 \ --strip-height 8 --jitter 4 --seed 42 photo1.jpg photo2.png ``` +The canvas is either a size in pixels (`-s 1920x1080`, the Custom entry in the +interface) or one of the standard formats, which `--help` lists in full: + +```bash +build/effector -o post.png --format instagram-post -e tiles +build/effector -o sheet.png --format a4 --dpi 300 -e scanlines +``` + +A web format — the Instagram sizes, Full HD, 4K UHD — is a fixed pixel size. A +print format — A5, A4, A3, US Letter — is a sheet of paper, and becomes pixels +at `--dpi` (36–1200, 300 by default), so A4 at 300 dpi is 2480×3508. Picking a +format in the interface fills the width and height in and greys them out; +Custom hands them back, keeping whatever the format last worked out to. A +format is saved with the project, so an A4 project reopens as A4 rather than as +the pixels it happened to be. + Any option can also be passed when launching the interface; it becomes the starting state (images, canvas size, effect settings, seed). diff --git a/src/cli/options.cpp b/src/cli/options.cpp index 307ac90..ac8437f 100644 --- a/src/cli/options.cpp +++ b/src/cli/options.cpp @@ -5,6 +5,7 @@ #include #include +#include "core/canvas.hpp" #include "core/chain.hpp" #include "core/effect.hpp" #include "core/registry.hpp" @@ -74,6 +75,20 @@ Options parseOptions(int argc, char** argv) { "each side between " + std::to_string(kMinCanvasSide) + " and " + std::to_string(kMaxCanvasSide)); } + } else if (arg == "--format") { + const std::string value = requireValue(argc, argv, i, arg); + if (canvasFormatByKey(value) == nullptr) { + throw EffectError("unknown canvas format '" + value + "', see --help"); + } + options.format = value; + } else if (arg == "--dpi") { + const std::string value = requireValue(argc, argv, i, arg); + long dpi = 0; + if (!parseLong(value, dpi) || dpi < kMinDpi || dpi > kMaxDpi) { + throw EffectError("invalid dpi '" + value + "', expected " + + std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi)); + } + options.dpi = static_cast(dpi); } else if (arg == "--seed") { const std::string value = requireValue(argc, argv, i, arg); long seed = 0; @@ -140,6 +155,11 @@ Options parseOptions(int argc, char** argv) { options.images.push_back(arg); } } + // Applied once everything is parsed, so --dpi may come before --format and + // so a format always wins over an -s given alongside it. + if (const auto* format = canvasFormatByKey(options.format)) { + canvasFormatSize(*format, options.dpi, options.width, options.height); + } if (options.effects.empty()) options.effects.push_back(defaultEffectName()); return options; } @@ -153,10 +173,21 @@ std::string usage(const char* programName) { << " -o, --output FILE render headlessly to this file and exit\n" << " -s, --size WxH canvas size, each side " << kMinCanvasSide << "-" << kMaxCanvasSide << " (default: 1920x1080)\n" + << " --format NAME canvas format, overrides --size (see below)\n" + << " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi + << " (default: " << kDefaultDpi << ")\n" << " --seed N random seed (default: random)\n" << " --list-effects list available effects and exit\n" << " -h, --help show this help and exit\n"; + out << "\nCanvas formats (--format):\n"; + for (const auto& format : canvasFormats()) { + out << " " << format.key; + if (format.key.size() < 20) out << std::string(20 - format.key.size(), ' '); + out << " " << format.label << ", " << canvasFormatDetail(format) + << (format.print ? " at --dpi" : "") << "\n"; + } + for (const auto& effect : effects()) { out << "\n" << effect->title() << " options:\n"; for (const auto& spec : effect->params()) { diff --git a/src/cli/options.hpp b/src/cli/options.hpp index 68897c8..e46e2f9 100644 --- a/src/cli/options.hpp +++ b/src/cli/options.hpp @@ -5,6 +5,7 @@ #include #include +#include "core/canvas.hpp" #include "core/chain.hpp" #include "core/params.hpp" @@ -16,6 +17,8 @@ struct Options { std::string output; // empty -> launch the interface int width = 1920; int height = 1080; + std::string format; // a named canvas format, empty for plain pixels + int dpi = kDefaultDpi; // only a print format is rendered at a resolution std::optional seed; ParamValues params; // only the keys the user actually passed bool listEffects = false; diff --git a/src/core/canvas.cpp b/src/core/canvas.cpp new file mode 100644 index 0000000..4b55bc2 --- /dev/null +++ b/src/core/canvas.cpp @@ -0,0 +1,76 @@ +#include "core/canvas.hpp" + +#include +#include +#include +#include + +#include "core/chain.hpp" + +namespace effector { +namespace { + +// 210 mm reads as "210", 215.9 mm as "215.9". In the C locale whatever the +// user's is: gtkmm sets the global locale from the environment, so a stream +// left to its default writes "210,0" on a German desktop. +std::string millimetres(double mm) { + std::ostringstream out; + out.imbue(std::locale::classic()); + out.setf(std::ios::fixed); + out.precision(1); + out << mm; + std::string text = out.str(); + if (text.size() > 2 && text.compare(text.size() - 2, 2, ".0") == 0) text.resize(text.size() - 2); + return text; +} + +} // namespace + +const std::vector& canvasFormats() { + // Written out once and handed back by reference: the table is fixed, and + // both front-ends walk it for their menu and their --help. + static const std::vector formats = { + {"instagram-post", "Instagram post", false, 1080, 1080, 0.0, 0.0}, + {"instagram-portrait", "Instagram portrait", false, 1080, 1350, 0.0, 0.0}, + {"instagram-story", "Instagram story", false, 1080, 1920, 0.0, 0.0}, + {"hd", "Full HD", false, 1920, 1080, 0.0, 0.0}, + {"uhd", "4K UHD", false, 3840, 2160, 0.0, 0.0}, + {"a5", "A5", true, 0, 0, 148.0, 210.0}, + {"a4", "A4", true, 0, 0, 210.0, 297.0}, + {"a3", "A3", true, 0, 0, 297.0, 420.0}, + {"letter", "US Letter", true, 0, 0, 215.9, 279.4}, + }; + return formats; +} + +const CanvasFormat* canvasFormatByKey(const std::string& key) { + if (key.empty()) return nullptr; + for (const auto& format : canvasFormats()) { + if (format.key == key) return &format; + } + return nullptr; +} + +void canvasFormatSize(const CanvasFormat& format, int dpi, int& width, int& height) { + if (!format.print) { + width = format.width; + height = format.height; + return; + } + const int clamped = std::clamp(dpi, kMinDpi, kMaxDpi); + const auto pixels = [clamped](double mm) { + const long value = std::lround(mm / 25.4 * clamped); + return static_cast(std::clamp(value, kMinCanvasSide, kMaxCanvasSide)); + }; + width = pixels(format.widthMm); + height = pixels(format.heightMm); +} + +std::string canvasFormatDetail(const CanvasFormat& format) { + if (format.print) { + return millimetres(format.widthMm) + " × " + millimetres(format.heightMm) + " mm"; + } + return std::to_string(format.width) + " × " + std::to_string(format.height) + " px"; +} + +} // namespace effector diff --git a/src/core/canvas.hpp b/src/core/canvas.hpp new file mode 100644 index 0000000..d1ca5cd --- /dev/null +++ b/src/core/canvas.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +namespace effector { + +// A named canvas size both front-ends offer, so "A4 at 300 dpi" is one choice +// rather than a pixel count the user has to work out. Web formats are a fixed +// pixel size; print formats are a physical size in millimetres and only become +// pixels once a resolution is given, which is why the dpi belongs to the +// canvas rather than to any one format. +struct CanvasFormat { + std::string key; // written in a project and given to --format, e.g. "a4" + std::string label; // shown in the interface, e.g. "A4" + bool print = false; + int width = 0; // pixels, web formats only + int height = 0; // + double widthMm = 0.0; // millimetres, print formats only + double heightMm = 0.0; +}; + +// The dpi a print format is rendered at. The bounds keep A3 — the largest +// sheet here — inside kMaxCanvasSide at the top end, and keep a sheet from +// coming out too small to see at the bottom. +inline constexpr int kMinDpi = 36; +inline constexpr int kMaxDpi = 1200; +inline constexpr int kDefaultDpi = 300; + +// In the order they are offered: the web formats first, then the paper sizes. +const std::vector& canvasFormats(); + +// Null when the key names no format, which is how a custom canvas — the user's +// own pixel sizes — is spelled everywhere: an empty or unknown key. +const CanvasFormat* canvasFormatByKey(const std::string& key); + +// The pixel size of a format. `dpi` is ignored by a web format, which already +// is a pixel size; a print format is rounded to the nearest pixel and clamped +// to the canvas bounds. +void canvasFormatSize(const CanvasFormat& format, int dpi, int& width, int& height); + +// "1080 × 1080 px" or "210 × 297 mm", for the dropdown and for --help. +std::string canvasFormatDetail(const CanvasFormat& format); + +} // namespace effector diff --git a/src/core/project.cpp b/src/core/project.cpp index bfc06d7..1d86f1e 100644 --- a/src/core/project.cpp +++ b/src/core/project.cpp @@ -10,6 +10,7 @@ #include #include +#include "core/canvas.hpp" #include "core/effect.hpp" #include "core/registry.hpp" @@ -306,6 +307,18 @@ int canvasSide(const Json& value, const char* what) { return static_cast(value.number); } +// Checked as a double before narrowing, exactly as a canvas side is. +int canvasDpi(const Json& value) { + if (value.kind != Json::Kind::Number || value.number != std::trunc(value.number)) { + throw EffectError("the canvas dpi must be a whole number"); + } + if (!(value.number >= kMinDpi) || !(value.number <= kMaxDpi)) { + throw EffectError("the canvas dpi must be between " + std::to_string(kMinDpi) + " and " + + std::to_string(kMaxDpi)); + } + 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; @@ -323,8 +336,15 @@ std::string projectToJson(const Project& project, const std::string& directory) out.imbue(std::locale::classic()); out << "{\n"; out << " \"effector_project\": 1,\n"; - out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height - << "},\n"; + out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height; + // The pixels are always written, so a project opens the same in a reader + // that knows nothing of formats; the format and its dpi come after them + // and only when there is one to name. + if (const auto* format = canvasFormatByKey(project.format)) { + out << ", \"format\": " << quote(format->key); + if (format->print) out << ", \"dpi\": " << project.dpi; + } + out << "},\n"; if (project.seed) out << " \"seed\": " << *project.seed << ",\n"; out << " \"sources\": ["; @@ -396,6 +416,18 @@ Project projectFromJson(const std::string& text, const std::string& directory) { if (const auto* height = canvas->member("height")) { project.height = canvasSide(*height, "height"); } + if (const auto* dpi = canvas->member("dpi")) project.dpi = canvasDpi(*dpi); + // A format the build does not know is simply a custom canvas: the + // pixels were written too, so the project still renders as it was + // saved. Where the format is known it decides the pixels, so a + // hand-edited dpi takes effect and a preset stays true to its name. + if (const auto* name = canvas->member("format"); name != nullptr && + name->kind == Json::Kind::String) { + if (const auto* format = canvasFormatByKey(name->text)) { + project.format = format->key; + canvasFormatSize(*format, project.dpi, project.width, project.height); + } + } } if (const auto* seed = root.member("seed")) { diff --git a/src/core/project.hpp b/src/core/project.hpp index 4f6bcdf..5034a45 100644 --- a/src/core/project.hpp +++ b/src/core/project.hpp @@ -5,6 +5,7 @@ #include #include +#include "core/canvas.hpp" #include "core/chain.hpp" #include "core/params.hpp" @@ -18,6 +19,12 @@ struct Project { std::vector stages; int width = 1920; int height = 1080; + // The named format the canvas came from, empty for a custom pixel size. + // The pixels above stay the truth for the render; this is what the + // interface puts back in its dropdown, and what a print format needs its + // resolution for. + std::string format; + int dpi = kDefaultDpi; std::optional seed; }; diff --git a/src/gui/window.cpp b/src/gui/window.cpp index 377487f..59a53f3 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -7,6 +7,7 @@ #include #include +#include "core/canvas.hpp" #include "core/chain.hpp" #include "core/project.hpp" #include "core/registry.hpp" @@ -175,28 +176,7 @@ Gtk::Widget& EffectorWindow::buildSidebar() { sidebar->append(sourcesExpander_); sidebar->append(*sectionLabel("Canvas")); - auto* grid = Gtk::make_managed(); - grid->set_column_spacing(8); - grid->set_row_spacing(6); - widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide); - widthSpin_.set_increments(1, 100); - widthSpin_.set_value(1920); - heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide); - heightSpin_.set_increments(1, 100); - heightSpin_.set_value(1080); - for (auto* spin : {&widthSpin_, &heightSpin_}) { - spin->set_hexpand(true); - spin->signal_value_changed().connect(sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); - } - auto* widthLabel = Gtk::make_managed("Width"); - widthLabel->set_xalign(0.0F); - auto* heightLabel = Gtk::make_managed("Height"); - heightLabel->set_xalign(0.0F); - grid->attach(*widthLabel, 0, 0); - grid->attach(widthSpin_, 1, 0); - grid->attach(*heightLabel, 0, 1); - grid->attach(heightSpin_, 1, 1); - sidebar->append(*grid); + sidebar->append(buildCanvasSection()); auto* seedBox = Gtk::make_managed(Gtk::Orientation::HORIZONTAL, 8); auto* seedLabel = Gtk::make_managed("Seed"); @@ -220,6 +200,122 @@ void EffectorWindow::updateSourcesLabel() { : "Source images (" + std::to_string(count) + ")"); } +// The canvas is either a named format — a web size, or a sheet of paper at a +// resolution — or the user's own pixels. A format owns the two spin buttons +// while it is picked, which is why they are shown greyed out rather than +// hidden: the size a format works out to is worth seeing. +Gtk::Widget& EffectorWindow::buildCanvasSection() { + auto* box = Gtk::make_managed(Gtk::Orientation::VERTICAL, 6); + + // Index 0 is "Custom"; every later index is canvasFormats()[index - 1]. + std::vector labels{"Custom"}; + for (const auto& format : canvasFormats()) { + labels.push_back(format.label + " (" + canvasFormatDetail(format) + ")"); + } + formatDrop_.set_model(Gtk::StringList::create(labels)); + formatDrop_.set_selected(0); + formatDrop_.set_tooltip_text("Standard web and print sizes, or your own pixels"); + formatDrop_.property_selected().signal_changed().connect( + sigc::mem_fun(*this, &EffectorWindow::syncCanvasWidgets)); + box->append(formatDrop_); + + auto* grid = Gtk::make_managed(); + grid->set_column_spacing(8); + grid->set_row_spacing(6); + widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide); + widthSpin_.set_increments(1, 100); + widthSpin_.set_value(1920); + heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide); + heightSpin_.set_increments(1, 100); + heightSpin_.set_value(1080); + dpiSpin_.set_range(kMinDpi, kMaxDpi); + dpiSpin_.set_increments(1, 50); + dpiSpin_.set_value(kDefaultDpi); + for (auto* spin : {&widthSpin_, &heightSpin_, &dpiSpin_}) { + spin->set_hexpand(true); + } + for (auto* spin : {&widthSpin_, &heightSpin_}) { + spin->signal_value_changed().connect([this] { + if (!suspendCanvasSync_) onParamChanged(); + }); + } + // The dpi decides the pixels of a sheet, so moving it goes through the + // same path a format change does. + dpiSpin_.signal_value_changed().connect([this] { + if (!suspendCanvasSync_) syncCanvasWidgets(); + }); + + auto* widthLabel = Gtk::make_managed("Width"); + widthLabel->set_xalign(0.0F); + auto* heightLabel = Gtk::make_managed("Height"); + heightLabel->set_xalign(0.0F); + auto* dpiLabel = Gtk::make_managed("DPI"); + dpiLabel->set_xalign(0.0F); + dpiLabel_ = dpiLabel; + grid->attach(*widthLabel, 0, 0); + grid->attach(widthSpin_, 1, 0); + grid->attach(*heightLabel, 0, 1); + grid->attach(heightSpin_, 1, 1); + grid->attach(*dpiLabel, 0, 2); + grid->attach(dpiSpin_, 1, 2); + box->append(*grid); + + // Quietly: the sidebar is still being built, and nothing is to be rendered + // until the initial options or a project have had their say. + suspendCanvasSync_ = true; + syncCanvasWidgets(); + suspendCanvasSync_ = false; + return *box; +} + +std::string EffectorWindow::canvasFormatKey() const { + const auto selected = formatDrop_.get_selected(); + if (selected == GTK_INVALID_LIST_POSITION || selected == 0) return {}; + const auto index = static_cast(selected) - 1; + const auto& formats = canvasFormats(); + return index < formats.size() ? formats[index].key : std::string(); +} + +// A key the build does not know — an old project, or a hand-edited file — lands +// on "Custom", which leaves the pixels the project carries alone. +void EffectorWindow::setCanvasFormat(const std::string& key, int dpi) { + const bool suspended = suspendCanvasSync_; + suspendCanvasSync_ = true; + dpiSpin_.set_value(std::clamp(dpi, kMinDpi, kMaxDpi)); + guint selected = 0; + const auto& formats = canvasFormats(); + for (std::size_t i = 0; i < formats.size(); ++i) { + if (formats[i].key == key) selected = static_cast(i + 1); + } + formatDrop_.set_selected(selected); + syncCanvasWidgets(); // still suspended, so the spins follow without a render + suspendCanvasSync_ = suspended; +} + +void EffectorWindow::syncCanvasWidgets() { + // Suspended means a caller is putting the canvas together — the initial + // options, or a project being opened — and will ask for the render itself. + const bool quiet = suspendCanvasSync_; + const auto* format = canvasFormatByKey(canvasFormatKey()); + const bool print = format != nullptr && format->print; + dpiLabel_->set_visible(print); + dpiSpin_.set_visible(print); + widthSpin_.set_sensitive(format == nullptr); + heightSpin_.set_sensitive(format == nullptr); + + if (format != nullptr) { + int width = 0; + int height = 0; + canvasFormatSize(*format, static_cast(dpiSpin_.get_value()), width, height); + const bool suspended = suspendCanvasSync_; + suspendCanvasSync_ = true; // one render for the change, not one per spin + widthSpin_.set_value(width); + heightSpin_.set_value(height); + suspendCanvasSync_ = suspended; + } + if (!quiet) onParamChanged(); +} + Gtk::Widget& EffectorWindow::buildPreview() { picture_.set_can_shrink(true); picture_.set_content_fit(Gtk::ContentFit::CONTAIN); @@ -429,6 +525,7 @@ void EffectorWindow::applyInitial(const Options& initial) { widthSpin_.set_value(initial.width); heightSpin_.set_value(initial.height); + setCanvasFormat(initial.format, initial.dpi); // a format fills the spins itself if (initial.seed) { seed_ = *initial.seed; seedEntry_.set_text(std::to_string(seed_)); @@ -1098,6 +1195,8 @@ Project EffectorWindow::currentProject() const { project.stages = stages_; project.width = static_cast(widthSpin_.get_value()); project.height = static_cast(heightSpin_.get_value()); + project.format = canvasFormatKey(); + project.dpi = static_cast(dpiSpin_.get_value()); project.seed = seed_; return project; } @@ -1114,6 +1213,7 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat widthSpin_.set_value(project.width); heightSpin_.set_value(project.height); + setCanvasFormat(project.format, project.dpi); seed_ = project.seed.value_or(Rng::randomSeed()); seedEntry_.set_text(std::to_string(seed_)); diff --git a/src/gui/window.hpp b/src/gui/window.hpp index d57d6c4..1deb21e 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -49,6 +49,14 @@ private: void cancelLoad(); void appendSourceRow(const std::string& path); void updateSourcesLabel(); // the collapsed expander shows the count + + Gtk::Widget& buildCanvasSection(); + // "" while "Custom" is picked, otherwise the format's key. + std::string canvasFormatKey() const; + void setCanvasFormat(const std::string& key, int dpi); // dropdown and dpi + // A format owns the pixel sizes, so the spins follow it and are greyed + // out; "Custom" hands them back to the user untouched. + void syncCanvasWidgets(); void removeSource(Gtk::ListBoxRow* row); Gtk::Widget& buildChainSection(); @@ -146,8 +154,14 @@ private: Gtk::Label* loadFileLabel_ = nullptr; Gtk::Expander sourcesExpander_; Gtk::ListBox sourceList_; + Gtk::DropDown formatDrop_; Gtk::SpinButton widthSpin_; Gtk::SpinButton heightSpin_; + Gtk::SpinButton dpiSpin_; + Gtk::Label* dpiLabel_ = nullptr; // hidden along with the dpi spin + // Set while a format fills the spins, so their own handler does not queue + // a second render on top of the one the format change asks for. + bool suspendCanvasSync_ = false; Gtk::Grid paramGrid_; Gtk::Label paramHeading_; Gtk::Entry seedEntry_; diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index da83af1..0872dac 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -16,6 +16,7 @@ #include #include "cli/options.hpp" +#include "core/canvas.hpp" #include "core/chain.hpp" #include "core/project.hpp" #include "core/registry.hpp" @@ -76,6 +77,25 @@ std::optional> canvasFromSize(const std::string& size) { } } +// The options a whole command line ends up as, or nothing when the parser +// refuses it. +std::optional parsedOptions(const std::vector& args) { + std::vector> storage; + storage.push_back({'e', 'f', 'f', 'e', 'c', 't', 'o', 'r', '\0'}); + for (const auto& arg : args) { + std::vector buffer(arg.begin(), arg.end()); + buffer.push_back('\0'); + storage.push_back(std::move(buffer)); + } + std::vector argv; + for (auto& buffer : storage) argv.push_back(buffer.data()); + try { + return effector::parseOptions(static_cast(argv.size()), argv.data()); + } catch (const effector::EffectError&) { + return std::nullopt; + } +} + bool sameBytes(const effector::Image& a, const effector::Image& b) { if (a.width() != b.width() || a.height() != b.height()) return false; return std::equal(a.data(), a.data() + a.byteSize(), b.data()); @@ -1062,6 +1082,120 @@ int main() { "-s refuses a side that is not positive"); } + std::cout << "canvas formats\n"; + { + using effector::canvasFormatByKey; + using effector::canvasFormats; + using effector::canvasFormatSize; + + // Every format the menu offers must land inside the canvas bounds at + // every resolution it can be asked for, or the dropdown would offer a + // size the renderer refuses. + int usable = 0; + for (const auto& format : canvasFormats()) { + int width = 0; + int height = 0; + bool ok = true; + for (const int dpi : {effector::kMinDpi, effector::kDefaultDpi, effector::kMaxDpi}) { + canvasFormatSize(format, dpi, width, height); + ok = ok && width >= kMinCanvasSide && width <= kMaxCanvasSide && + height >= kMinCanvasSide && height <= kMaxCanvasSide; + } + if (ok) ++usable; + } + check(usable == static_cast(canvasFormats().size()), + "every format stays inside the canvas bounds at every dpi"); + + int unique = 0; + for (const auto& format : canvasFormats()) { + if (canvasFormatByKey(format.key) == &format) ++unique; + } + check(unique == static_cast(canvasFormats().size()), "format keys are unique"); + check(canvasFormatByKey("") == nullptr && canvasFormatByKey("a6") == nullptr, + "an empty or unknown key is a custom canvas"); + + int width = 0; + int height = 0; + canvasFormatSize(*canvasFormatByKey("a4"), 300, width, height); + check(width == 2480 && height == 3508, "A4 at 300 dpi is 2480x3508"); + canvasFormatSize(*canvasFormatByKey("a4"), 600, width, height); + check(width == 4961 && height == 7016, "doubling the dpi doubles the sheet"); + canvasFormatSize(*canvasFormatByKey("instagram-story"), 600, width, height); + check(width == 1080 && height == 1920, "a web format ignores the dpi"); + + // The CLI: a format decides the canvas whichever side of -s it is on, + // and --dpi may come before the format it applies to. + const auto sheet = parsedOptions({"--format", "a4", "--dpi", "600"}); + check(sheet && sheet->width == 4961 && sheet->height == 7016, + "--format a4 --dpi 600 sets the canvas"); + const auto reordered = parsedOptions({"--dpi", "600", "--format", "a4"}); + check(reordered && reordered->width == 4961, "--dpi may be given before --format"); + const auto both = parsedOptions({"-s", "800x600", "--format", "instagram-post"}); + check(both && both->width == 1080 && both->height == 1080, "--format overrides -s"); + const auto custom = parsedOptions({"-s", "800x600"}); + check(custom && custom->format.empty() && custom->width == 800, + "-s alone leaves the canvas custom"); + check(!parsedOptions({"--format", "a6"}), "--format refuses an unknown format"); + check(!parsedOptions({"--dpi", "0"}) && !parsedOptions({"--dpi", "5000"}) && + !parsedOptions({"--dpi", "many"}), + "--dpi refuses a resolution outside the bounds"); + + // A project keeps the format, and the format keeps the pixels: a dpi + // edited by hand moves the canvas with it. + Project sheetProject; + sheetProject.stages.push_back({effectByName("blur"), {}}); + sheetProject.format = "a4"; + sheetProject.dpi = 600; + canvasFormatSize(*canvasFormatByKey("a4"), 600, sheetProject.width, sheetProject.height); + const auto backProject = projectFromJson(projectToJson(sheetProject)); + check(backProject.format == "a4" && backProject.dpi == 600 && backProject.width == 4961, + "a print format and its dpi survive a round trip"); + + const auto retuned = projectFromJson( + "{\"canvas\": {\"width\": 800, \"height\": 600, \"format\": \"a4\", " + "\"dpi\": 300},\n \"chain\": [{\"effect\": \"blur\"}]}"); + check(retuned.width == 2480 && retuned.height == 3508, + "a named format decides the pixels over whatever was written beside it"); + + // A format this build does not know — a newer file, or a typo — is a + // custom canvas, so the pixels the file carries are still rendered. + const auto unknown = projectFromJson( + "{\"canvas\": {\"width\": 800, \"height\": 600, \"format\": \"a6\"},\n" + " \"chain\": [{\"effect\": \"blur\"}]}"); + check(unknown.format.empty() && unknown.width == 800 && unknown.height == 600, + "an unknown format falls back to the canvas the file names"); + + Project plainProject; + plainProject.stages.push_back({effectByName("blur"), {}}); + plainProject.width = 800; + plainProject.height = 600; + const auto plainText = projectToJson(plainProject); + check(plainText.find("format") == std::string::npos && + plainText.find("dpi") == std::string::npos, + "a custom canvas writes no format and no dpi"); + + const auto webText = projectToJson([] { + Project web; + web.stages.push_back({effectByName("blur"), {}}); + web.format = "hd"; + return web; + }()); + check(webText.find("\"format\": \"hd\"") != std::string::npos && + webText.find("dpi") == std::string::npos, + "a web format writes no dpi it would never use"); + + int badDpi = 0; + for (const char* bad : {"0", "5000", "300.5", "\"300\""}) { + try { + projectFromJson(std::string("{\"canvas\": {\"dpi\": ") + bad + + "},\n \"chain\": [{\"effect\": \"blur\"}]}"); + } catch (const EffectError&) { + ++badDpi; + } + } + check(badDpi == 4, "a dpi outside the bounds is refused"); + } + std::cout << "image io\n"; { const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";