Let several text stages share one named look
A picture with a title, a caption and a credit meant typing the same font, size and colour into every text stage, and changing the look meant typing them all again. A project now holds named styles: a stage follows one, and editing it moves every stage that follows it at once. The front-ends learn nothing effect-specific. A ParamSpec can be marked .styled(), saying a style carries it rather than the stage, and Effect::styleKind() names the pool it comes from -- so styleSpecs(kind) is to a style what params() is to a stage, and a second text-ish effect would slot into the styles that already exist. The look is what a style carries; the words and the placement stay on the stage, which is what lets two stages share a typography in two different corners. stageValues() resolves one in a single place, and a stage keeps a full set of values of its own beside the style, so switching it to Custom, dropping the style, or opening the project in a build that does not know it renders exactly the same picture. A style moves stages together; it never holds the only copy of anything. The command line builds no styles and passes none, so it renders what it always did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// Small self-contained checks for the pieces that have no GUI dependency.
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <clocale>
|
||||
@@ -21,6 +22,7 @@
|
||||
#include "core/project.hpp"
|
||||
#include "core/registry.hpp"
|
||||
#include "core/rng.hpp"
|
||||
#include "core/style.hpp"
|
||||
#include "effects/blur.hpp"
|
||||
#include "effects/grain.hpp"
|
||||
#include "effects/monochrome.hpp"
|
||||
@@ -828,6 +830,109 @@ int main() {
|
||||
std::filesystem::remove_all(dir);
|
||||
}
|
||||
|
||||
std::cout << "text styles\n";
|
||||
{
|
||||
const std::vector<Image> blue = {solid(200, 120, 0, 0, 255)};
|
||||
const auto countRed = [](const Image& image) {
|
||||
int found = 0;
|
||||
for (int y = 0; y < image.height(); ++y) {
|
||||
for (int x = 0; x < image.width(); ++x) {
|
||||
if (isRed(pixel(image, x, y))) ++found;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const auto specs = styleSpecs("text");
|
||||
const auto carries = [&specs](const std::string& key) {
|
||||
return std::any_of(specs.begin(), specs.end(),
|
||||
[&key](const ParamSpec& spec) { return spec.key == key; });
|
||||
};
|
||||
check(specs.size() == 11, "a text style carries eleven params");
|
||||
check(carries("font_family") && carries("font_weight") && carries("font_italic") &&
|
||||
carries("font_size") && carries("text_color") && carries("text_align") &&
|
||||
carries("text_opacity") && carries("text_box") && carries("box_color") &&
|
||||
carries("box_padding") && carries("box_opacity"),
|
||||
"the look is what it carries");
|
||||
check(!carries("text") && !carries("text_wrap") && !carries("box_image") &&
|
||||
!carries("text_center") && !carries("text_offset_x") && !carries("text_offset_y"),
|
||||
"the words and the placement stay on the stage");
|
||||
check(styleSpecs("").empty() && styleSpecs("nosuchkind").empty(),
|
||||
"a kind no effect claims carries nothing");
|
||||
check(paramDouble(styleDefaults("text"), "font_size", 0.0) == 96.0 &&
|
||||
styleDefaults("text").size() == specs.size(),
|
||||
"a fresh style starts at the defaults the effect declares");
|
||||
|
||||
StyleSet styles = {{"text", "Title", styleDefaults("text")}};
|
||||
styles[0].values["font_size"] = 40.0;
|
||||
styles[0].values["text_color"] = std::string("#ff0000");
|
||||
check(findStyle(styles, "text", "Title") != nullptr &&
|
||||
findStyle(styles, "text", "Caption") == nullptr &&
|
||||
findStyle(styles, "other", "Title") == nullptr,
|
||||
"a style is found by its kind and its name");
|
||||
|
||||
const ChainStage own{effectByName("text"),
|
||||
{{"text", std::string("Hi")},
|
||||
{"font_size", 12.0},
|
||||
{"text_color", std::string("#ff0000")}},
|
||||
true,
|
||||
""};
|
||||
ChainStage follows = own;
|
||||
follows.style = "Title";
|
||||
|
||||
check(paramDouble(stageValues(own, styles), "font_size", 0.0) == 12.0,
|
||||
"a stage on its own keeps the settings it was given");
|
||||
check(paramDouble(stageValues(follows, styles), "font_size", 0.0) == 40.0,
|
||||
"a stage following a style takes the style's");
|
||||
check(paramText(stageValues(follows, styles), "text", "") == "Hi",
|
||||
"and keeps its own words either way");
|
||||
|
||||
Rng ownRng(3);
|
||||
Rng followRng(3);
|
||||
const auto small = renderChain({own}, blue, 200, 120, ownRng, styles);
|
||||
const auto large = renderChain({follows}, blue, 200, 120, followRng, styles);
|
||||
check(countRed(small) > 0 && countRed(large) > countRed(small),
|
||||
"the style is what actually reaches the render");
|
||||
|
||||
// The whole point: two stages, one style, one look in two places.
|
||||
ChainStage left = follows;
|
||||
left.values["text_offset_x"] = -40L;
|
||||
ChainStage right = follows;
|
||||
right.values["text_offset_x"] = 40L;
|
||||
Rng leftRng(3);
|
||||
Rng rightRng(3);
|
||||
const auto leftImage = renderChain({left}, blue, 200, 120, leftRng, styles);
|
||||
const auto rightImage = renderChain({right}, blue, 200, 120, rightRng, styles);
|
||||
check(countRed(leftImage) == countRed(large) && countRed(rightImage) == countRed(large),
|
||||
"two stages following one style ink the same glyphs");
|
||||
check(!sameBytes(leftImage, rightImage), "each in its own place");
|
||||
|
||||
StyleSet edited = styles;
|
||||
edited[0].values["font_size"] = 20.0;
|
||||
Rng editedLeft(3);
|
||||
Rng editedRight(3);
|
||||
const auto afterLeft = renderChain({left}, blue, 200, 120, editedLeft, edited);
|
||||
const auto afterRight = renderChain({right}, blue, 200, 120, editedRight, edited);
|
||||
check(countRed(afterLeft) < countRed(leftImage) &&
|
||||
countRed(afterRight) < countRed(rightImage) &&
|
||||
countRed(afterLeft) == countRed(afterRight),
|
||||
"editing the style moves every stage that follows it");
|
||||
|
||||
ChainStage missing = follows;
|
||||
missing.style = "Nowhere";
|
||||
Rng missingRng(3);
|
||||
Rng bareRng(3);
|
||||
check(sameBytes(renderChain({missing}, blue, 200, 120, missingRng, styles),
|
||||
renderChain({own}, blue, 200, 120, bareRng)),
|
||||
"naming a style that is not there renders as standing alone");
|
||||
|
||||
Rng noStyleRng(3);
|
||||
Rng noPoolRng(3);
|
||||
check(sameBytes(renderChain({follows}, blue, 200, 120, noStyleRng, {}),
|
||||
renderChain({own}, blue, 200, 120, noPoolRng)),
|
||||
"and so does a chain handed no styles at all");
|
||||
}
|
||||
|
||||
std::cout << "chain\n";
|
||||
{
|
||||
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
||||
@@ -1008,6 +1113,71 @@ int main() {
|
||||
projectToJson(project).find("\"enabled\"") == std::string::npos,
|
||||
"only a switched-off stage is written out as one");
|
||||
|
||||
// Styles ride along with the chain, and the stages come back pointing
|
||||
// at the ones they followed.
|
||||
Project stylish;
|
||||
stylish.styles = {{"text", "Title", styleDefaults("text")},
|
||||
{"text", "Caption", styleDefaults("text")}};
|
||||
stylish.styles[0].values["font_size"] = 120.0;
|
||||
stylish.styles[0].values["text_color"] = std::string("#ff8800");
|
||||
stylish.styles[1].values["font_family"] = std::string("Serif");
|
||||
stylish.stages.push_back(
|
||||
{effectByName("text"), {{"text", std::string("One")}}, true, "Title"});
|
||||
stylish.stages.push_back(
|
||||
{effectByName("text"), {{"text", std::string("Two")}}, true, "Title"});
|
||||
stylish.stages.push_back(
|
||||
{effectByName("text"), {{"text", std::string("Three")}}, true, ""});
|
||||
|
||||
const auto styleFile = dir / "styles.json";
|
||||
saveProject(styleFile.string(), stylish);
|
||||
const auto styleBack = loadProject(styleFile.string());
|
||||
check(styleBack.styles.size() == 2 && styleBack.styles[0].name == "Title" &&
|
||||
styleBack.styles[1].name == "Caption" && styleBack.styles[0].kind == "text",
|
||||
"the styles survive a round trip");
|
||||
check(paramDouble(styleBack.styles[0].values, "font_size", 0.0) == 120.0 &&
|
||||
paramText(styleBack.styles[0].values, "text_color", "") == "#ff8800" &&
|
||||
paramText(styleBack.styles[1].values, "font_family", "") == "Serif",
|
||||
"and so do the values they carry");
|
||||
check(styleBack.stages[0].style == "Title" && styleBack.stages[1].style == "Title" &&
|
||||
styleBack.stages[2].style.empty(),
|
||||
"and which stage follows which");
|
||||
check(paramDouble(stageValues(styleBack.stages[0], styleBack.styles), "font_size", 0.0) ==
|
||||
120.0 &&
|
||||
paramDouble(stageValues(styleBack.stages[2], styleBack.styles), "font_size",
|
||||
0.0) == 96.0,
|
||||
"a reloaded project resolves its styles as it did");
|
||||
check(projectToJson(stylish).find("\"styles\"") != std::string::npos &&
|
||||
projectToJson(project).find("\"styles\"") == std::string::npos &&
|
||||
projectToJson(project).find("\"style\"") == std::string::npos,
|
||||
"a project with no styles writes nothing about them");
|
||||
|
||||
// Everything that can be wrong with a style is dropped on its own,
|
||||
// leaving a project that still opens and still renders.
|
||||
const auto lenient = projectFromJson(R"({
|
||||
"styles": [
|
||||
{"kind": "nosuchkind", "name": "Ghost", "params": {}},
|
||||
{"kind": "text", "name": "", "params": {}},
|
||||
{"kind": "text", "name": "Title", "params": {"font_size": 60, "nonsense": 3}},
|
||||
{"kind": "text", "name": "Title", "params": {"font_size": 12}}
|
||||
],
|
||||
"chain": [
|
||||
{"effect": "text", "style": "Title", "params": {"text": "Kept"}},
|
||||
{"effect": "text", "style": "Gone", "params": {"font_size": 30}},
|
||||
{"effect": "blur", "style": "Title", "params": {}}
|
||||
]
|
||||
})");
|
||||
check(lenient.styles.size() == 1 && lenient.styles[0].name == "Title",
|
||||
"an unknown kind, a nameless style and a repeated name are dropped");
|
||||
check(paramDouble(lenient.styles[0].values, "font_size", 0.0) == 60.0 &&
|
||||
lenient.styles[0].values.find("nonsense") == lenient.styles[0].values.end(),
|
||||
"a style only keeps the params its kind declares, and the first of a name wins");
|
||||
check(lenient.stages[0].style == "Title" && lenient.stages[1].style.empty(),
|
||||
"a stage naming a style that is not there is cut loose");
|
||||
check(lenient.stages[2].style.empty(),
|
||||
"and so is one whose effect takes no style at all");
|
||||
check(paramDouble(stageValues(lenient.stages[1], lenient.styles), "font_size", 0.0) == 30.0,
|
||||
"which leaves it rendering the values written beside it");
|
||||
|
||||
int refusals = 0;
|
||||
for (const char* bad : {"{\"chain\": [{\"effect\": \"nope\"}]}", "{\"chain\": []}",
|
||||
"{\"chain\": [{\"effect\": \"blur\"},]}", "not json at all"}) {
|
||||
|
||||
Reference in New Issue
Block a user