OmaPhoto 1.0.0: the Linux port of Compositor
A layered image editor for Linux and Omarchy, ported from Compositor (github.com/robbietilton/Compositor), whose Swift source is its specification. It reads and writes Compositor's .comp projects. Packages for Ubuntu, Fedora and Arch come from the release workflow; scripts/install/ holds one install script per distribution family, and flake.nix serves NixOS. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
c864304695
564 files changed
+100804
No files matched your search
@@ -0,0 +1,242 @@
|
||||
#include "IO/ProjectStore+Json.h"
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QRegularExpression>
|
||||
#include <QVariant>
|
||||
|
||||
// Swift's synthesized Codable by hand: same keys, same refusals.
|
||||
void ManifestJson::refuse()
|
||||
{
|
||||
throw ProjectError(ProjectError::Kind::invalid);
|
||||
}
|
||||
|
||||
QString ManifestJson::string(const QJsonValue &value)
|
||||
{
|
||||
if (!value.isString())
|
||||
refuse();
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
bool ManifestJson::boolean(const QJsonValue &value)
|
||||
{
|
||||
if (!value.isBool())
|
||||
refuse();
|
||||
return value.toBool();
|
||||
}
|
||||
|
||||
double ManifestJson::number(const QJsonValue &value)
|
||||
{
|
||||
if (!value.isDouble())
|
||||
refuse();
|
||||
return value.toDouble();
|
||||
}
|
||||
|
||||
qint64 ManifestJson::integer(const QJsonValue &value)
|
||||
{
|
||||
const QVariant exact = value.toVariant();
|
||||
if (exact.typeId() != QMetaType::LongLong)
|
||||
refuse();
|
||||
return exact.toLongLong();
|
||||
}
|
||||
|
||||
QUuid ManifestJson::uuid(const QJsonValue &value)
|
||||
{
|
||||
// Anchored at both ends: `$` would pass a trailing newline.
|
||||
static const QRegularExpression canonical(
|
||||
QRegularExpression::anchoredPattern("[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}"));
|
||||
const QString text = string(value);
|
||||
if (!canonical.match(text).hasMatch())
|
||||
refuse();
|
||||
return QUuid::fromString(text);
|
||||
}
|
||||
|
||||
namespace {
|
||||
using namespace ManifestJson;
|
||||
|
||||
// CGPoint and CGSize: two numbers; what is missing is refused.
|
||||
std::pair<double, double> pair(const QJsonValue &value)
|
||||
{
|
||||
return {number(value.toArray().at(0)), number(value.toArray().at(1))};
|
||||
}
|
||||
|
||||
LayerTransform transform(const QJsonValue &value)
|
||||
{
|
||||
const QJsonObject object = value.toObject();
|
||||
const auto [x, y] = pair(object.value("origin"));
|
||||
const auto [width, height] = pair(object.value("size"));
|
||||
const std::optional<LayerSampling> sampling = layerSampling(string(object.value("sampling")));
|
||||
if (!sampling)
|
||||
refuse();
|
||||
return {.origin = {x, y}, .size = {width, height}, .rotation = number(object.value("rotation")),
|
||||
.flipX = boolean(object.value("flipX")), .flipY = boolean(object.value("flipY")), .sampling = *sampling};
|
||||
}
|
||||
|
||||
LayerBlendMode blendMode(const QJsonValue &value)
|
||||
{
|
||||
const std::optional<LayerBlendMode> mode = layerBlendMode(string(value));
|
||||
if (!mode)
|
||||
refuse();
|
||||
return *mode;
|
||||
}
|
||||
|
||||
QPointF point(const QJsonValue &value)
|
||||
{
|
||||
const auto [x, y] = pair(value);
|
||||
return {x, y};
|
||||
}
|
||||
|
||||
LayerShapeStyle shape(const QJsonValue &value)
|
||||
{
|
||||
const QJsonObject object = value.toObject();
|
||||
const std::optional<ShapeKind> kind = shapeKind(string(object.value("kind")));
|
||||
if (!kind)
|
||||
refuse();
|
||||
return {.kind = *kind, .red = number(object.value("red")), .green = number(object.value("green")), .blue = number(object.value("blue")),
|
||||
.cornerRadius = number(object.value("cornerRadius")), .lineWidth = optional(object, "lineWidth", number),
|
||||
.start = optional(object, "start", point), .end = optional(object, "end", point)};
|
||||
}
|
||||
|
||||
QSizeF size(const QJsonValue &value)
|
||||
{
|
||||
const auto [width, height] = pair(value);
|
||||
return {width, height};
|
||||
}
|
||||
|
||||
// Synthesized Codable skips defaults: all but the box required.
|
||||
LayerTextStyle textStyle(const QJsonValue &value)
|
||||
{
|
||||
const QJsonObject object = value.toObject();
|
||||
const std::optional<TextAlignment> alignment = textAlignment(string(object.value("alignment")));
|
||||
if (!alignment)
|
||||
refuse();
|
||||
return {.content = string(object.value("content")), .fontName = string(object.value("fontName")), .fontSize = number(object.value("fontSize")),
|
||||
.red = number(object.value("red")), .green = number(object.value("green")), .blue = number(object.value("blue")),
|
||||
.alignment = *alignment, .tracking = number(object.value("tracking")), .leading = number(object.value("leading")),
|
||||
.boxSize = optional(object, "boxSize", size)};
|
||||
}
|
||||
|
||||
ProjectLayerRecord layer(const QJsonValue &value)
|
||||
{
|
||||
const QJsonObject object = value.toObject();
|
||||
return {.id = uuid(object.value("id")), .name = string(object.value("name")), .isVisible = boolean(object.value("isVisible")),
|
||||
.transform = transform(object.value("transform")), .imageFile = optional(object, "imageFile", string),
|
||||
.parentID = optional(object, "parentID", uuid), .isGroup = optional(object, "isGroup", boolean),
|
||||
.opacity = optional(object, "opacity", number), .blendMode = optional(object, "blendMode", blendMode),
|
||||
.maskFile = optional(object, "maskFile", string), .maskEnabled = optional(object, "maskEnabled", boolean),
|
||||
.maskSourceID = optional(object, "maskSourceID", uuid), .adjustment = optional(object, "adjustment", ManifestJson::adjustment),
|
||||
.maskPlacement = optional(object, "maskPlacement", transform),
|
||||
.maskLinked = optional(object, "maskLinked", boolean), .shape = optional(object, "shape", shape),
|
||||
.effects = optional(object, "effects", ManifestJson::effects), .text = optional(object, "text", textStyle)};
|
||||
}
|
||||
|
||||
QJsonObject encoded(const LayerTransform &transform)
|
||||
{
|
||||
return {{"origin", QJsonArray{transform.origin.x(), transform.origin.y()}},
|
||||
{"size", QJsonArray{transform.size.width(), transform.size.height()}},
|
||||
{"rotation", transform.rotation}, {"flipX", transform.flipX}, {"flipY", transform.flipY},
|
||||
{"sampling", rawValue(transform.sampling)}};
|
||||
}
|
||||
|
||||
QJsonObject encoded(const LayerShapeStyle &shape)
|
||||
{
|
||||
QJsonObject object{{"kind", rawValue(shape.kind)}, {"red", shape.red}, {"green", shape.green}, {"blue", shape.blue},
|
||||
{"cornerRadius", shape.cornerRadius}};
|
||||
if (shape.lineWidth)
|
||||
object.insert("lineWidth", *shape.lineWidth);
|
||||
if (shape.start)
|
||||
object.insert("start", QJsonArray{shape.start->x(), shape.start->y()});
|
||||
if (shape.end)
|
||||
object.insert("end", QJsonArray{shape.end->x(), shape.end->y()});
|
||||
return object;
|
||||
}
|
||||
|
||||
QJsonObject encoded(const LayerTextStyle &text)
|
||||
{
|
||||
QJsonObject object{{"content", text.content}, {"fontName", text.fontName}, {"fontSize", text.fontSize}, {"red", text.red}, {"green", text.green},
|
||||
{"blue", text.blue}, {"alignment", rawValue(text.alignment)}, {"tracking", text.tracking}, {"leading", text.leading}};
|
||||
if (text.boxSize)
|
||||
object.insert("boxSize", QJsonArray{text.boxSize->width(), text.boxSize->height()});
|
||||
return object;
|
||||
}
|
||||
|
||||
QJsonObject encoded(const ProjectLayerRecord &layer)
|
||||
{
|
||||
QJsonObject object{{"id", uuidString(layer.id)}, {"name", layer.name}, {"isVisible", layer.isVisible},
|
||||
{"transform", encoded(layer.transform)}};
|
||||
if (layer.imageFile)
|
||||
object.insert("imageFile", *layer.imageFile);
|
||||
if (layer.parentID)
|
||||
object.insert("parentID", uuidString(*layer.parentID));
|
||||
if (layer.isGroup)
|
||||
object.insert("isGroup", *layer.isGroup);
|
||||
if (layer.opacity)
|
||||
object.insert("opacity", *layer.opacity);
|
||||
if (layer.blendMode)
|
||||
object.insert("blendMode", rawValue(*layer.blendMode));
|
||||
if (layer.maskFile)
|
||||
object.insert("maskFile", *layer.maskFile);
|
||||
if (layer.maskEnabled)
|
||||
object.insert("maskEnabled", *layer.maskEnabled);
|
||||
if (layer.maskSourceID)
|
||||
object.insert("maskSourceID", uuidString(*layer.maskSourceID));
|
||||
if (layer.adjustment)
|
||||
object.insert("adjustment", ManifestJson::encoded(*layer.adjustment));
|
||||
if (layer.maskPlacement)
|
||||
object.insert("maskPlacement", encoded(*layer.maskPlacement));
|
||||
if (layer.maskLinked)
|
||||
object.insert("maskLinked", *layer.maskLinked);
|
||||
if (layer.shape)
|
||||
object.insert("shape", encoded(*layer.shape));
|
||||
if (layer.effects)
|
||||
object.insert("effects", ManifestJson::encoded(*layer.effects));
|
||||
if (layer.text)
|
||||
object.insert("text", encoded(*layer.text));
|
||||
return object;
|
||||
}
|
||||
}
|
||||
|
||||
QString uuidString(const QUuid &id)
|
||||
{
|
||||
return id.toString(QUuid::WithoutBraces).toUpper();
|
||||
}
|
||||
|
||||
QByteArray ProjectManifest::encoded() const
|
||||
{
|
||||
QJsonArray records;
|
||||
for (const ProjectLayerRecord &layer : layers)
|
||||
records.append(::encoded(layer));
|
||||
QJsonObject object{{"format", format}, {"version", version}, {"colorSpace", colorSpace}, {"documentID", uuidString(documentID)},
|
||||
{"width", width}, {"height", height}, {"layers", records}};
|
||||
if (resolution)
|
||||
object.insert("resolution", *resolution);
|
||||
if (activeLayerID)
|
||||
object.insert("activeLayerID", uuidString(*activeLayerID));
|
||||
// Swift indents by two; four would outgrow 4 MiB sooner.
|
||||
QByteArray json;
|
||||
for (const QByteArray &line : QJsonDocument(object).toJson(QJsonDocument::Indented).split('\n')) {
|
||||
const qsizetype indent = line.size() - line.trimmed().size();
|
||||
json += line.mid(indent / 2) + '\n';
|
||||
}
|
||||
json.chop(1);
|
||||
return json;
|
||||
}
|
||||
|
||||
ProjectManifest ProjectManifest::decoded(const QByteArray &data)
|
||||
{
|
||||
const QJsonObject object = QJsonDocument::fromJson(data).object();
|
||||
if (!object.value("layers").isArray())
|
||||
refuse();
|
||||
std::vector<ProjectLayerRecord> records;
|
||||
for (const QJsonValue &value : object.value("layers").toArray())
|
||||
records.push_back(layer(value));
|
||||
return {.format = string(object.value("format")), .version = integer(object.value("version")),
|
||||
.colorSpace = string(object.value("colorSpace")), .resolution = optional(object, "resolution", number),
|
||||
.documentID = uuid(object.value("documentID")), .width = integer(object.value("width")),
|
||||
.height = integer(object.value("height")), .activeLayerID = optional(object, "activeLayerID", uuid), .layers = records};
|
||||
}
|
||||
|
||||
std::pair<QString, qint64> ProjectManifest::header(const QByteArray &data)
|
||||
{
|
||||
const QJsonObject object = QJsonDocument::fromJson(data).object();
|
||||
return {string(object.value("format")), integer(object.value("version"))};
|
||||
}
|
||||
Reference in new issue
Block a user