Offer standard web and print canvas formats

The canvas was two spin buttons, so "an Instagram story" or "A4 at 300 dpi"
was a number the user had to work out. src/core/canvas.cpp now holds one
table of standard sizes both front-ends read: a web format is a fixed pixel
size, a print format 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 the 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, and where one is named it
decides the pixels — so a project saved as A4 reopens as A4 rather than as
whatever it happened to measure.

In the interface a format fills the width and height in and greys them out
rather than hiding them: the size a sheet works out to is worth seeing. A
print format also shows a DPI row, and Custom hands the spins back keeping
the last size, which makes starting from a format and then tweaking it the
ordinary path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-18 22:34:17 +02:00
co-authored by Claude Opus 5
parent f4d8457828
commit d48a81c665
12 changed files with 494 additions and 24 deletions
+134
View File
@@ -16,6 +16,7 @@
#include <vector>
#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<std::pair<int, int>> canvasFromSize(const std::string& size) {
}
}
// The options a whole command line ends up as, or nothing when the parser
// refuses it.
std::optional<effector::Options> parsedOptions(const std::vector<std::string>& args) {
std::vector<std::vector<char>> storage;
storage.push_back({'e', 'f', 'f', 'e', 'c', 't', 'o', 'r', '\0'});
for (const auto& arg : args) {
std::vector<char> buffer(arg.begin(), arg.end());
buffer.push_back('\0');
storage.push_back(std::move(buffer));
}
std::vector<char*> argv;
for (auto& buffer : storage) argv.push_back(buffer.data());
try {
return effector::parseOptions(static_cast<int>(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<int>(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<int>(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";