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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user