OmaPhoto 1.0.0: the Linux port of Compositor
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s

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:
雷电芽衣andClaude committed 2026-09-26 00:44:52 -04:00
commit c864304695
564 files changed
+100804

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
#include "IO/CanvasResizer.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QPainter>
#include <algorithm>
#include <cmath>
#include <new>
namespace {
ProjectSnapshot resized(const ProjectSnapshot &snapshot, const CanvasSizeOptions &options)
{
if (options.width < 1 || options.width > 30'000 || options.height < 1 || options.height > 30'000 || options.anchor < 0
|| options.anchor > 8)
throw ProjectError(ProjectError::Kind::tooLarge);
const ProjectManifest &old = snapshot.manifest;
const QPointF offset = options.offset(old.width, old.height);
// NaN and infinity fail the comparison.
if (!(std::abs(offset.x()) <= 1'000'000 && std::abs(offset.y()) <= 1'000'000))
throw ProjectError(ProjectError::Kind::invalid);
if (options.width == old.width && options.height == old.height && offset == QPointF(0, 0))
return snapshot;
ProjectManifest manifest{.resolution = old.resolution, .documentID = old.documentID, .width = options.width,
.height = options.height, .activeLayerID = old.activeLayerID, .layers = {}};
for (const ProjectLayerRecord &layer : old.layers) {
LayerTransform transform = layer.transform;
transform.origin += offset;
if (!transform.isValid())
throw ProjectError(ProjectError::Kind::tooLarge);
std::optional<LayerTransform> placement = layer.maskPlacement;
if (placement)
placement->origin += offset;
// Field by field, as Swift: what it omits is dropped.
manifest.layers.push_back({.id = layer.id, .name = layer.name, .isVisible = layer.isVisible, .transform = transform,
.imageFile = layer.imageFile, .parentID = layer.parentID, .isGroup = layer.isGroup,
.opacity = layer.opacity, .blendMode = layer.blendMode, .maskFile = layer.maskFile,
.maskEnabled = layer.maskEnabled, .maskSourceID = layer.maskSourceID, .adjustment = layer.adjustment,
.maskPlacement = placement,
.maskLinked = layer.maskLinked, .shape = layer.shape,
.text = layer.text});
}
std::map<QUuid, ImportedImage> images = snapshot.images;
// The fill: a bottom layer, clear over the old canvas.
if (options.fill && (options.width > old.width || options.height > old.height)) {
qint64 used = 0;
for (const auto &[id, image] : images)
used += qint64(image.size().width()) * image.size().height();
if (options.width * options.height > 100'000'000 - used || manifest.layers.size() >= 10'000)
throw ProjectError(ProjectError::Kind::tooLarge);
const CanvasExtensionColor &color = *options.fill;
for (const double part : {color.red, color.green, color.blue}) {
if (!(part >= 0 && part <= 1))
throw ProjectError(ProjectError::Kind::invalid);
}
QImage image(int(options.width), int(options.height), QImage::Format_RGBA8888_Premultiplied);
if (image.isNull())
throw ExportError(ExportError::Kind::render);
image.fill(QColor::fromRgbF(float(color.red), float(color.green), float(color.blue)));
{
QPainter context(&image);
context.setCompositionMode(QPainter::CompositionMode_Clear);
context.fillRect(QRectF(offset, QSizeF(double(old.width), double(old.height))), Qt::transparent);
}
const double factor = std::min(1.0, 96 / double(std::max(options.width, options.height)));
const QSize small(std::max(1, int(double(options.width) * factor)), std::max(1, int(double(options.height) * factor)));
const QImage thumbnail = image.scaled(small, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (thumbnail.isNull())
throw ExportError(ExportError::Kind::render);
const QUuid id = QUuid::createUuid();
const QString name = QStringLiteral("Canvas Extension");
images.insert({id, ImportedImage(image, thumbnail, name)});
manifest.layers.insert(manifest.layers.begin(),
{.id = id, .name = name, .isVisible = true,
.transform = {.origin = {0, 0}, .size = {double(options.width), double(options.height)}},
.imageFile = uuidString(id) + QStringLiteral(".png")});
}
qCInfo(lcIO) << "canvas" << old.width << "x" << old.height << "becomes" << options.width << "x" << options.height;
return {.manifest = manifest, .images = images, .masks = snapshot.masks};
}
}
ProjectSnapshot CanvasResizer::resize(const ProjectSnapshot &snapshot, const CanvasSizeOptions &options)
{
// Records and maps without memory fail as a render.
try {
return resized(snapshot, options);
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include "Document/CanvasSize.h"
#include "IO/ProjectStore.h"
namespace CanvasResizer {
// Moves the layers; a fill becomes a bottom layer.
ProjectSnapshot resize(const ProjectSnapshot &snapshot, const CanvasSizeOptions &options);
}
+173
View File
@@ -0,0 +1,173 @@
#include "IO/ImageExporter.h"
#include "Document/LayerEffects+Renderer.h"
#include "Document/LayerGroups.h"
#include "Document/LayerMask.h"
#include "Document/LiveLayerMask.h"
#include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include "Rendering/LiveMaskRenderer.h"
#include <QBuffer>
#include <QColorSpace>
#include <QImageReader>
#include <QImageWriter>
#include <QSaveFile>
#include <algorithm>
#include <cmath>
namespace {
const char *description(ExportError::Kind kind)
{
switch (kind) {
case ExportError::Kind::tooLarge:
return "Image export supports canvases up to 100 megapixels and 30,000 pixels per side.";
case ExportError::Kind::render:
return "The canvas could not be rendered. Try a smaller canvas.";
case ExportError::Kind::encode:
return "The image could not be encoded.";
}
throw std::logic_error("unknown ExportError kind");
}
// Quality runs 0 to 100; -1 means the format's own.
QByteArray encode(QImage image, const char *format, int quality, double resolution)
{
const int dotsPerMeter = qRound(resolution / 0.0254);
image.setDotsPerMeterX(dotsPerMeter);
image.setDotsPerMeterY(dotsPerMeter);
image.setColorSpace(QColorSpace::SRgb);
QByteArray data;
QBuffer buffer(&data);
QImageWriter writer(&buffer, format);
writer.setQuality(quality);
if (!writer.write(image))
throw ExportError(ExportError::Kind::encode);
return data;
}
}
ExportError::ExportError(Kind kind) : std::runtime_error(description(kind)), kind(kind) {}
ExportRaster ImageExporter::render(const ProjectSnapshot &snapshot)
try {
const qint64 width = snapshot.manifest.width, height = snapshot.manifest.height;
if (width < 1 || width > 30'000 || height < 1 || height > 30'000 || width * height > 100'000'000)
throw ExportError(ExportError::Kind::tooLarge);
QImage image(int(width), int(height), QImage::Format_RGBA8888_Premultiplied);
if (image.isNull())
throw ExportError(ExportError::Kind::render);
image.fill(0);
const std::vector<ProjectLayerRecord> &layers = snapshot.manifest.layers;
std::map<QUuid, ProjectLayerRecord> records;
for (const ProjectLayerRecord &layer : layers) {
if ((layer.imageFile && !snapshot.images.contains(layer.id)) || (layer.maskFile && !snapshot.masks.contains(layer.id)))
throw ProjectError(ProjectError::Kind::missingImage);
records.insert({layer.id, layer});
}
LiveMaskGraph::validate(layers);
const auto parent = [&](QUuid id) { return records.contains(id) ? records.at(id).parentID : std::nullopt; };
LiveMaskRenderer live([&](QUuid id) { return records.contains(id) ? records.at(id).maskSourceID : std::nullopt; },
[&](QUuid id, QPainter &target, const QImage &clip) {
const auto asset = snapshot.images.find(id);
if (!records.contains(id) || asset == snapshot.images.end())
return;
const ProjectLayerRecord &layer = records.at(id);
const QImage pixels = asset->second.image();
const std::optional<LayerMask> mask = snapshot.mask(layer);
const std::optional<QImage> shown =
mask ? mask->clipImage(mask->placement, layer.transform, pixels.width(), pixels.height()) : std::nullopt;
const double opacity = layer.opacity.value_or(1);
const LayerBlendMode mode = layer.blendMode.value_or(LayerBlendMode::normal);
// With effects the mask is in their image already.
if (const auto effects = LayerEffectsRenderer::cached(pixels, shown, layer.effects)) {
const LayerTransform grown = LayerEffectsRenderer::placed(layer.transform, effects->image, effects->inset);
LayerRenderer::draw(effects->image, grown, grown.center(), target, {.opacity = opacity, .blendMode = mode, .clip = clip});
return;
}
LayerRenderer::draw(pixels, layer.transform, layer.transform.center(), target,
{.opacity = opacity, .blendMode = mode, .mask = shown.value_or(QImage()), .clip = clip});
});
live.adjustment = [&](QUuid id) { return records.contains(id) ? records.at(id).adjustment : std::nullopt; };
live.adjustmentOpacity = [&](QUuid id) { return records.at(id).opacity.value_or(1); };
live.adjustmentClip = [&](QUuid id, const QPainter &context, QImage &coverage) {
const ProjectLayerRecord &layer = records.at(id);
const std::optional<LayerMask> mask = snapshot.mask(layer);
if (const std::optional<QImage> image = mask ? mask->enabledImage() : std::nullopt)
FolderMaskClip{*image, layer.transform}.apply(layer.transform.center(), context, coverage);
};
std::vector<QUuid> visible;
for (const ProjectLayerRecord &layer : LayerHierarchy::visibleLayers(layers))
visible.push_back(layer.id);
live.prepareStacks(visible, parent, [&](QUuid id) { return records.at(id).blendMode.value_or(LayerBlendMode::normal); });
{
QPainter painter(&image);
FolderMaskClip::draw(visible, parent, [&](QUuid id) -> std::optional<FolderMaskClip::Applier> {
const std::optional<LayerMask> mask = records.contains(id) ? snapshot.mask(records.at(id)) : std::nullopt;
const std::optional<QImage> enabled = mask ? mask->enabledImage() : std::nullopt;
if (!enabled)
return std::nullopt;
const FolderMaskClip clip{*enabled, records.at(id).transform};
return [clip](const QPainter &context, QImage &coverage) { clip.apply(clip.transform.center(), context, coverage); };
}, painter, [&](QUuid id, const QImage &clip) { live.drawComposite(id, painter, clip); });
}
qCInfo(lcIO) << "rendered" << width << "x" << height << "from" << visible.size() << "visible layers";
return {image, snapshot.manifest.resolution.value_or(72)};
} catch (const std::bad_alloc &) {
// Memory that runs out fails the render, as contexts do.
throw ExportError(ExportError::Kind::render);
}
QByteArray ImageExporter::pngData(const ProjectSnapshot &snapshot)
{
const ExportRaster raster = render(snapshot);
return encode(raster.image, "png", -1, raster.resolution);
}
JPEGResult ImageExporter::jpeg(const ExportRaster &raster, const JPEGOptions &options, const std::function<bool()> &cancelled)
try {
const auto check = [&cancelled] {
if (cancelled && cancelled())
throw CancellationError();
};
check();
// JPEG has no alpha: the image lies over the matte.
QImage flattened(raster.image.size(), QImage::Format_RGB888);
if (flattened.isNull())
throw ExportError(ExportError::Kind::render);
flattened.fill(QColor::fromRgbF(float(options.red), float(options.green), float(options.blue)));
{
QPainter painter(&flattened);
painter.drawImage(QRectF(flattened.rect()), raster.image);
}
check();
QByteArray data = encode(flattened, "jpeg", qRound(std::clamp(options.quality, 0.0, 1.0) * 100), raster.resolution);
check();
// The preview shows what the file holds, compression and all.
QBuffer buffer(&data);
QImageReader reader(&buffer, "jpeg");
const QSize size = reader.size();
// A thin image keeps one pixel across.
if (std::max(size.width(), size.height()) > 1000)
reader.setScaledSize(size.scaled(1000, 1000, Qt::KeepAspectRatio).expandedTo(QSize(1, 1)));
const QImage preview = reader.read();
if (preview.isNull())
throw ExportError(ExportError::Kind::encode);
return {data, preview};
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
void ImageExporter::exportPNG(const ProjectSnapshot &snapshot, const QString &path)
{
write(pngData(snapshot), path);
}
void ImageExporter::write(const QByteArray &data, const QString &path)
{
QSaveFile file(path);
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit()) {
qCWarning(lcIO) << "could not write" << path << ":" << file.errorString();
throw std::runtime_error(file.errorString().toStdString());
}
qCInfo(lcIO) << "wrote" << data.size() << "bytes to" << path;
}
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "IO/ProjectStore.h"
#include <QByteArray>
#include <QImage>
#include <functional>
#include <stdexcept>
class ExportError : public std::runtime_error {
public:
enum class Kind { tooLarge, render, encode };
explicit ExportError(Kind kind);
const Kind kind;
};
// Swift's CancellationError: a newer request superseded this one.
class CancellationError : public std::exception {};
struct ExportRaster {
QImage image;
// Pixels per inch.
double resolution = 72;
};
struct JPEGOptions {
double quality = 0.85;
// The matte behind transparent pixels.
double red = 1;
double green = 1;
double blue = 1;
bool operator==(const JPEGOptions &) const = default;
};
struct JPEGResult {
QByteArray data;
// Decoded from `data`, at most 1000 pixels a side.
QImage preview;
};
namespace ImageExporter {
ExportRaster render(const ProjectSnapshot &snapshot);
QByteArray pngData(const ProjectSnapshot &snapshot);
// Stops at Swift's three points once `cancelled` says so.
JPEGResult jpeg(const ExportRaster &raster, const JPEGOptions &options, const std::function<bool()> &cancelled = {});
void exportPNG(const ProjectSnapshot &snapshot, const QString &path);
// Replaces the file in one step, or not at all.
void write(const QByteArray &data, const QString &path);
}
+104
View File
@@ -0,0 +1,104 @@
#include "IO/ImageFileDrop.h"
#include "Document/ProjectWorkspace.h"
#include "Logging.h"
#include <QDir>
#include <QFile>
#include <QMimeDatabase>
#include <algorithm>
namespace {
// Swift's order: PNG, JPEG, HEIC, TIFF, then any picture.
QStringList pictureFormats(const QMimeData &data)
{
QStringList formats;
for (const QString &format : {QStringLiteral("image/png"), QStringLiteral("image/jpeg"), QStringLiteral("image/heic"), QStringLiteral("image/tiff")})
if (data.hasFormat(format))
formats << format;
for (const QString &format : data.formats())
if (format.startsWith(QLatin1String("image/")) && !formats.contains(format))
formats << format;
return formats;
}
// A dropped picture copied to a file for the importer.
std::optional<QUrl> temporaryFile(const QMimeData &data)
{
const QStringList formats = pictureFormats(data);
if (formats.isEmpty())
return std::nullopt;
const QString suffix = QMimeDatabase().mimeTypeForName(formats.first()).preferredSuffix();
const QString name = QStringLiteral("Dropped-%1.%2").arg(QUuid::createUuid().toString(QUuid::WithoutBraces).toUpper(), suffix.isEmpty() ? QStringLiteral("png") : suffix);
QFile file(QDir::temp().filePath(name));
const QByteArray bytes = data.data(formats.first());
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.flush()) {
qCWarning(lcIO) << "could not keep a dropped picture:" << file.errorString();
// A failed flush fails `remove()` too: close, then unlink.
file.close();
QFile::remove(file.fileName());
return std::nullopt;
}
return QUrl::fromLocalFile(file.fileName());
}
}
bool ImageFileDrop::holdsImages(const QMimeData &data)
{
for (const QUrl &url : data.urls())
if (url.isLocalFile())
return true;
return !pictureFormats(data).isEmpty();
}
std::vector<std::shared_ptr<QMimeData>> ImageFileDrop::providers(const QMimeData &data)
{
std::vector<std::shared_ptr<QMimeData>> each;
const QList<QUrl> urls = data.urls();
if (std::any_of(urls.begin(), urls.end(), [](const QUrl &url) { return url.isLocalFile(); })) {
for (const QUrl &url : urls) {
auto provider = std::make_shared<QMimeData>();
provider->setUrls({url});
each.push_back(provider);
}
return each;
}
// No file: a picture with its address is one item.
auto whole = std::make_shared<QMimeData>();
// Swift asks for the preferred picture alone.
const QStringList formats = pictureFormats(data);
if (!formats.isEmpty())
whole->setData(formats.first(), data.data(formats.first()));
each.push_back(whole);
return each;
}
void ImageFileDrop::importProviders(const QMimeData &data, EditorSession &session, std::optional<QPointF> point, ProjectWorkspace *workspace,
std::optional<QUuid> destination, std::function<void()> done)
{
QList<QUrl> urls;
bool unreadable = false;
for (const QUrl &url : data.urls()) {
if (url.isLocalFile())
urls << url;
else
unreadable = true;
}
// Not a file on disk: a browser's or screenshot's picture.
if (urls.isEmpty()) {
const std::optional<QUrl> copy = temporaryFile(data);
unreadable = !copy;
if (copy)
urls << *copy;
}
const auto finished = [&session, unreadable, done] {
if (unreadable) {
const QString message = QStringLiteral("Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, or TIFF files from the file manager.");
session.setImportError(session.importError() ? *session.importError() + QStringLiteral("\n\n") + message : message);
}
if (done)
done();
};
if (workspace)
workspace->receive(urls, destination, point, finished);
else
session.importImages(urls, point, finished);
}
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <QMimeData>
#include <QPointF>
#include <QUuid>
#include <functional>
#include <memory>
#include <vector>
#include <optional>
class EditorSession;
class ProjectWorkspace;
// Swift's ImageFileDrop: dropped files and pictures, in order, imported.
namespace ImageFileDrop {
void importProviders(const QMimeData &data, EditorSession &session, std::optional<QPointF> point, ProjectWorkspace *workspace = nullptr,
std::optional<QUuid> destination = std::nullopt, std::function<void()> done = {});
// Swift's drop types: a file on disk or a picture.
bool holdsImages(const QMimeData &data);
// A drag as Swift's providers: a URL each, else whole.
std::vector<std::shared_ptr<QMimeData>> providers(const QMimeData &data);
}
+243
View File
@@ -0,0 +1,243 @@
#include "IO/ImageImporter.h"
#include "Logging.h"
#include "Rendering/RasterSnapshot.h"
#include <QColorSpace>
#include <QFile>
#include <QFileInfo>
#include <QImageReader>
#include <QTransform>
#include <cmath>
#include <libheif/heif.h>
namespace {
QString description(ImageImportError::Kind kind)
{
switch (kind) {
case ImageImportError::Kind::unreadable:
return QStringLiteral("The image could not be read. It may be damaged or unavailable.");
case ImageImportError::Kind::unsupported:
return QStringLiteral("Choose a JPEG, PNG, HEIC, or TIFF image.");
case ImageImportError::Kind::tooLarge:
return QStringLiteral("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit.");
}
Q_UNREACHABLE();
}
[[noreturn]] void refuse(ImageImportError::Kind kind, const QString &path, const QString &reason)
{
qCWarning(lcIO).noquote() << "cannot import" << path + ":" << reason;
throw ImageImportError(kind);
}
void checkSize(const QSize &size, qint64 remainingPixels, const QString &path)
{
if (size.width() <= 0 || size.height() <= 0)
refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("it states no size"));
if (size.width() > 30'000 || size.height() > 30'000 || qint64(size.width()) * size.height() > remainingPixels)
refuse(ImageImportError::Kind::tooLarge, path, QStringLiteral("%1 x %2 pixels").arg(size.width()).arg(size.height()));
}
// HEVC in HEIF, by libheif's own reading of the brand.
bool isHEIC(const QByteArray &head)
{
const char *type = heif_get_file_mime_type(reinterpret_cast<const uint8_t *>(head.constData()), int(head.size()));
return qstrcmp(type, "image/heic") == 0;
}
// BT.709's curve, shared by BT.601 and BT.2020, as a table.
QList<uint16_t> videoCurve()
{
QList<uint16_t> table(4096);
for (int index = 0; index < table.size(); ++index) {
const double value = index / double(table.size() - 1);
table[index] = uint16_t(std::lround(65535 * (value < 0.081 ? value / 4.5 : std::pow((value + 0.099) / 1.099, 1 / 0.45))));
}
return table;
}
// A HEIC's colours: its ICC profile, else its nclx box.
QColorSpace statedColorSpace(const heif_image_handle *handle, const QString &path)
{
QByteArray profile(qsizetype(heif_image_handle_get_raw_color_profile_size(handle)), Qt::Uninitialized);
if (heif_image_handle_get_raw_color_profile(handle, profile.data()).code == heif_error_Ok)
return QColorSpace::fromIccProfile(profile);
heif_color_profile_nclx *box = nullptr;
const heif_error error = heif_image_handle_get_nclx_color_profile(handle, &box);
// No profile at all: untagged pixels count as sRGB.
if (error.code == heif_error_Color_profile_does_not_exist)
return QColorSpace();
if (error.code != heif_error_Ok)
refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("its nclx colour box: ") + QString::fromUtf8(error.message));
const std::unique_ptr<heif_color_profile_nclx, void (*)(heif_color_profile_nclx *)> nclx(box, heif_nclx_color_profile_free);
// Unspecified primaries or curve count as sRGB's.
QColorSpace stated(QColorSpace::SRgb);
if (nclx->color_primaries != heif_color_primaries_unspecified)
stated = QColorSpace(QPointF(nclx->color_primary_white_x, nclx->color_primary_white_y),
QPointF(nclx->color_primary_red_x, nclx->color_primary_red_y),
QPointF(nclx->color_primary_green_x, nclx->color_primary_green_y),
QPointF(nclx->color_primary_blue_x, nclx->color_primary_blue_y), QColorSpace::TransferFunction::SRgb);
if (!stated.isValid())
refuse(ImageImportError::Kind::unreadable, path,
QStringLiteral("colour primaries %1 have no conversion to sRGB here").arg(int(nclx->color_primaries)));
switch (nclx->transfer_characteristics) {
case heif_transfer_characteristic_IEC_61966_2_1:
case heif_transfer_characteristic_unspecified:
return stated;
case heif_transfer_characteristic_linear:
stated.setTransferFunction(QColorSpace::TransferFunction::Linear);
return stated;
case heif_transfer_characteristic_ITU_R_BT_470_6_System_M:
stated.setTransferFunction(QColorSpace::TransferFunction::Gamma, 2.2f);
return stated;
case heif_transfer_characteristic_ITU_R_BT_470_6_System_B_G:
stated.setTransferFunction(QColorSpace::TransferFunction::Gamma, 2.8f);
return stated;
// xvYCC and BT.1361 are BT.709's curve between zero and one.
case heif_transfer_characteristic_IEC_61966_2_4:
case heif_transfer_characteristic_ITU_R_BT_1361:
case heif_transfer_characteristic_ITU_R_BT_709_5:
case heif_transfer_characteristic_ITU_R_BT_601_6:
case heif_transfer_characteristic_ITU_R_BT_2020_2_10bit:
case heif_transfer_characteristic_ITU_R_BT_2020_2_12bit:
// Qt 6.4's constructor from points and a table is broken.
stated.setTransferFunction(videoCurve());
return stated;
default:
refuse(ImageImportError::Kind::unreadable, path,
QStringLiteral("transfer characteristics %1 have no conversion to sRGB here").arg(int(nclx->transfer_characteristics)));
}
}
// libheif turns and crops the picture as the file says.
QImage decodeHEIC(const QString &path, qint64 remainingPixels)
{
// libheif's documented frame: deinit frees what init loaded.
struct Library {
Library() { heif_init(nullptr); }
~Library() { heif_deinit(); }
} library;
const std::unique_ptr<heif_context, void (*)(heif_context *)> context(heif_context_alloc(), heif_context_free);
heif_error error = heif_context_read_from_file(context.get(), QFile::encodeName(path).constData(), nullptr);
if (error.code != heif_error_Ok)
refuse(ImageImportError::Kind::unreadable, path, QString::fromUtf8(error.message));
heif_image_handle *primary = nullptr;
error = heif_context_get_primary_image_handle(context.get(), &primary);
if (error.code != heif_error_Ok)
refuse(ImageImportError::Kind::unreadable, path, QString::fromUtf8(error.message));
const std::unique_ptr<heif_image_handle, void (*)(const heif_image_handle *)> handle(primary, heif_image_handle_release);
checkSize(QSize(heif_image_handle_get_width(primary), heif_image_handle_get_height(primary)), remainingPixels, path);
const QColorSpace stated = statedColorSpace(primary, path);
heif_image *decoded = nullptr;
error = heif_decode_image(primary, &decoded, heif_colorspace_RGB, heif_chroma_interleaved_RGBA, nullptr);
if (error.code != heif_error_Ok)
refuse(ImageImportError::Kind::unreadable, path, QString::fromUtf8(error.message));
const std::unique_ptr<heif_image, void (*)(const heif_image *)> picture(decoded, heif_image_release);
int stride = 0;
const uint8_t *pixels = heif_image_get_plane_readonly(decoded, heif_channel_interleaved, &stride);
if (!pixels)
refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("libheif gave no pixels"));
const bool premultiplied = heif_image_handle_is_premultiplied_alpha(primary);
// The copy outlives libheif's buffer.
QImage image = QImage(pixels, heif_image_get_width(decoded, heif_channel_interleaved), heif_image_get_height(decoded, heif_channel_interleaved),
stride, premultiplied ? QImage::Format_RGBA8888_Premultiplied : QImage::Format_RGBA8888).copy();
image.setColorSpace(stated);
return image;
}
}
ImportedImage::ImportedImage(QImage image, QImage thumbnail, QString name)
: thumbnail(std::move(thumbnail)), name(std::move(name)), m_image(std::move(image))
{
}
ImportedImage::ImportedImage(std::shared_ptr<const RasterSnapshot> raster, QImage thumbnail, QString name)
: thumbnail(std::move(thumbnail)), name(std::move(name)), raster(std::move(raster))
{
}
QSize ImportedImage::size() const
{
return raster ? QSize(raster->width, raster->height) : m_image.size();
}
QImage ImportedImage::image() const
{
return raster ? raster->makeImage() : m_image;
}
ImageIdentity ImportedImage::identity() const
{
return raster ? ImageIdentity{raster.get(), 0} : ImageIdentity{nullptr, m_image.cacheKey()};
}
qint64 ImportedImage::byteCount() const
{
if (!raster)
return m_image.sizeInBytes();
return qint64(raster->width) * raster->height * (raster->isMask ? 1 : 4);
}
ImageImportError::ImageImportError(Kind kind) : std::runtime_error(description(kind).toStdString()), kind(kind) {}
void ImageImporter::liftAllocationLimit()
{
static const bool lifted = (QImageReader::setAllocationLimit(0), true);
Q_UNUSED(lifted)
}
ImportedImage ImageImporter::decode(const QString &path, qint64 remainingPixels)
{
liftAllocationLimit();
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
refuse(ImageImportError::Kind::unreadable, path, file.errorString());
// The type comes from the bytes: Qt checks them too.
const QByteArray head = file.peek(64);
QImageReader reader(&file);
const QByteArray format = reader.format();
const bool heic = isHEIC(head);
// Other ISO media is unwanted: AVIF, plain HEIF, video.
if (!heic && head.mid(4, 4) == "ftyp")
refuse(ImageImportError::Kind::unsupported, path, QStringLiteral("ISO media that is not HEIC"));
if (!heic && format.isEmpty())
refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("no image format is recognised"));
if (!heic && format != "jpeg" && format != "png" && format != "tiff")
refuse(ImageImportError::Kind::unsupported, path, QString::fromLatin1(format));
// Qt's own turning drops the colour space: turn afterwards.
QImageIOHandler::Transformations orientation = QImageIOHandler::TransformationNone;
QImage image;
if (heic) {
image = decodeHEIC(path, remainingPixels);
} else {
checkSize(reader.size(), remainingPixels, path);
orientation = reader.transformation();
if (!reader.read(&image))
refuse(ImageImportError::Kind::unreadable, path, reader.errorString());
}
image.convertToColorSpace(QColorSpace::SRgb);
image = std::move(image).convertToFormat(QImage::Format_RGBA8888_Premultiplied);
// Qt 6.9 renamed mirrored as flipped; the floor stays 6.4.
#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)
Qt::Orientations flips;
flips.setFlag(Qt::Horizontal, orientation.testFlag(QImageIOHandler::TransformationMirror));
flips.setFlag(Qt::Vertical, orientation.testFlag(QImageIOHandler::TransformationFlip));
image = std::move(image).flipped(flips);
#else
image = std::move(image).mirrored(orientation.testFlag(QImageIOHandler::TransformationMirror),
orientation.testFlag(QImageIOHandler::TransformationFlip));
#endif
if (orientation.testFlag(QImageIOHandler::TransformationRotate90))
image = image.transformed(QTransform().rotate(90));
if (image.isNull())
refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("out of memory"));
image.setColorSpace(QColorSpace::SRgb);
// Swift: scale = min(1, 96 / longest), extent rounded outward.
const qint64 longest = std::max(image.width(), image.height());
const QSize small(int((image.width() * 96 + longest - 1) / longest), int((image.height() * 96 + longest - 1) / longest));
const QImage thumbnail = longest <= 96 ? image : image.scaled(small, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (thumbnail.isNull())
refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("out of memory"));
const QString name = QFileInfo(path).completeBaseName();
qCInfo(lcIO).noquote() << "imported" << path << image.width() << "x" << image.height();
return ImportedImage(image, thumbnail, name.isEmpty() ? QFileInfo(path).fileName() : name);
}
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include <QImage>
#include <QString>
#include <compare>
#include <memory>
#include <stdexcept>
class RasterSnapshot;
// Which pixels an asset holds, as CGImage identity did.
struct ImageIdentity {
const RasterSnapshot *raster = nullptr;
qint64 cacheKey = 0;
auto operator<=>(const ImageIdentity &) const = default;
};
class ImportedImage {
public:
ImportedImage(QImage image, QImage thumbnail, QString name);
ImportedImage(std::shared_ptr<const RasterSnapshot> raster, QImage thumbnail, QString name);
QSize size() const;
// Painted tiles flatten on first use.
QImage image() const;
ImageIdentity identity() const;
qint64 byteCount() const;
QImage thumbnail;
QString name;
std::shared_ptr<const RasterSnapshot> raster;
private:
QImage m_image;
};
class ImageImportError : public std::runtime_error {
public:
enum class Kind { unreadable, unsupported, tooLarge };
explicit ImageImportError(Kind kind);
Kind kind;
};
namespace ImageImporter {
// Qt refuses decodes over 128 MB; the app's budgets rule.
void liftAllocationLimit();
// JPEG, PNG, TIFF or HEIC: upright, sRGB, premultiplied.
ImportedImage decode(const QString &path, qint64 remainingPixels = 100'000'000);
}
+4
View File
@@ -0,0 +1,4 @@
// Swift's ImageResizer extension: resized projects applied.
public:
void applyImageSize(const ProjectSnapshot &snapshot);
void applyDocumentSize(const ProjectSnapshot &snapshot, const QString &actionName);
+140
View File
@@ -0,0 +1,140 @@
#include "IO/ImageResizer.h"
#include "Document/EditorSession.h"
#include "Document/LayerMask.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <QPainter>
#include <algorithm>
#include <cmath>
#include <new>
namespace {
// A surface over the scaled layer, in document coordinates.
QImage drawn(QImage::Format format, qint64 width, qint64 height, QPointF topLeft, double sx, double sy,
const std::function<void(QPainter &)> &body)
{
QImage image(int(width), int(height), format);
if (image.isNull())
throw ExportError(ExportError::Kind::render);
image.fill(0);
QPainter context(&image);
context.translate(-topLeft.x(), -topLeft.y());
context.scale(sx, sy);
body(context);
return image;
}
ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions &options)
{
// NaN fails both tests of a range.
if (options.width < 1 || options.width > 30'000 || options.height < 1 || options.height > 30'000
|| !(options.resolution >= 1 && options.resolution <= 9600))
throw ProjectError(ProjectError::Kind::tooLarge);
const ProjectManifest &old = snapshot.manifest;
ProjectManifest manifest{.resolution = options.resolution, .documentID = old.documentID, .width = options.width,
.height = options.height, .activeLayerID = old.activeLayerID, .layers = {}};
if (old.width == options.width && old.height == options.height) {
manifest.layers = old.layers;
return {.manifest = manifest, .images = snapshot.images, .masks = snapshot.masks};
}
if (options.width * options.height > 100'000'000)
throw ProjectError(ProjectError::Kind::tooLarge);
const double sx = double(options.width) / double(old.width), sy = double(options.height) / double(old.height);
std::map<QUuid, ImportedImage> images, masks;
qint64 usedPixels = 0, usedMaskPixels = 0;
for (const ProjectLayerRecord &layer : old.layers) {
// Each layer is drawn again: unequal scaling shears turned ones.
double left = INFINITY, top = INFINITY, right = -INFINITY, bottom = -INFINITY;
for (const QPointF unit : {QPointF(0, 0), QPointF(1, 0), QPointF(1, 1), QPointF(0, 1)}) {
const QPointF corner = layer.transform.point(unit);
left = std::min(left, corner.x() * sx), right = std::max(right, corner.x() * sx);
top = std::min(top, corner.y() * sy), bottom = std::max(bottom, corner.y() * sy);
}
left = std::floor(left), top = std::floor(top);
const LayerTransform transform{.origin = {left, top}, .size = {std::ceil(right) - left, std::ceil(bottom) - top},
.sampling = options.sampling};
// Judged before any number becomes an integer.
if (!transform.isValid())
throw ProjectError(ProjectError::Kind::tooLarge);
const qint64 width = qint64(transform.size.width()), height = qint64(transform.size.height());
LayerTransform sourceTransform = layer.transform;
sourceTransform.sampling = options.sampling;
if (layer.imageFile) {
if (width > 30'000 || height > 30'000 || width * height > 100'000'000 - usedPixels)
throw ProjectError(ProjectError::Kind::tooLarge);
usedPixels += width * height;
const auto source = snapshot.images.find(layer.id);
if (source == snapshot.images.end())
throw ProjectError(ProjectError::Kind::missingImage);
const QImage image = drawn(QImage::Format_RGBA8888_Premultiplied, width, height, {left, top}, sx, sy, [&](QPainter &context) {
LayerRenderer::draw(source->second.image(), sourceTransform, sourceTransform.center(), context);
});
const double factor = std::min(1.0, 96 / double(std::max(width, height)));
const QSize small(std::max(1, int(double(width) * factor)), std::max(1, int(double(height) * factor)));
const QImage thumbnail = image.scaled(small, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (thumbnail.isNull())
throw ExportError(ExportError::Kind::render);
images.insert({layer.id, ImportedImage(image, thumbnail, source->second.name)});
}
if (layer.maskFile) {
const auto source = snapshot.masks.find(layer.id);
if (source == snapshot.masks.end())
throw ProjectError(ProjectError::Kind::missingImage);
// Solid masks have no resolution; placed ones keep their pixels.
if (source->second.size() == QSize(1, 1) || layer.maskPlacement) {
masks.insert({layer.id, source->second});
} else {
if (width > 30'000 || height > 30'000 || width * height > 100'000'000 - usedMaskPixels)
throw ProjectError(ProjectError::Kind::tooLarge);
usedMaskPixels += width * height;
const QImage image = drawn(QImage::Format_Grayscale8, width, height, {left, top}, sx, sy, [&](QPainter &context) {
LayerRenderer::drawCoverage(source->second.image(), sourceTransform, context);
});
masks.insert({layer.id, LayerMask::assetFrom(image)});
}
}
std::optional<LayerTransform> placement;
if (layer.maskPlacement)
placement = layer.maskPlacement->placing(layer.maskPlacement->unitToDocument() * QTransform::fromScale(sx, sy));
manifest.layers.push_back({.id = layer.id, .name = layer.name, .isVisible = layer.isVisible, .transform = transform,
.imageFile = layer.imageFile, .parentID = layer.parentID, .isGroup = layer.isGroup,
.opacity = layer.opacity, .blendMode = layer.blendMode, .maskFile = layer.maskFile,
.maskEnabled = layer.maskEnabled, .maskSourceID = layer.maskSourceID, .adjustment = layer.adjustment,
.maskPlacement = placement,
.maskLinked = layer.maskLinked});
}
qCInfo(lcIO) << "resized" << old.width << "x" << old.height << "to" << options.width << "x" << options.height;
return {.manifest = manifest, .images = images, .masks = masks};
}
}
ProjectSnapshot ImageResizer::resize(const ProjectSnapshot &snapshot, const ImageSizeOptions &options)
{
// Records, maps and tiles without memory fail as a render.
try {
return resized(snapshot, options);
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
}
void EditorSession::applyImageSize(const ProjectSnapshot &snapshot)
{
applyDocumentSize(snapshot, QStringLiteral("Image Size"));
}
// Staged aside, then published: running out of memory changes nothing.
void EditorSession::applyDocumentSize(const ProjectSnapshot &snapshot, const QString &actionName)
{
if (!m_document || m_document->id != snapshot.manifest.documentID)
return;
std::optional<CanvasDocument> next = CanvasDocument(snapshot);
DocumentHistory recorded = history;
recorded.begin(actionName, m_document, m_activeLayerID);
recorded.end(next, m_activeLayerID);
m_document = std::move(next);
history.adopt(std::move(recorded));
viewport.fit(m_document->size());
notify();
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include "Document/LayerTransform.h"
#include "IO/ProjectStore.h"
struct ImageSizeOptions {
qint64 width;
qint64 height;
double resolution;
LayerSampling sampling = LayerSampling::high;
};
namespace ImageResizer {
// Scales the document; every layer is drawn again upright.
ProjectSnapshot resize(const ProjectSnapshot &snapshot, const ImageSizeOptions &options);
}
+171
View File
@@ -0,0 +1,171 @@
#include "Document/ProjectWorkspace.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectController.h"
#include "UI/JPEGExportSheet.h"
#include <QDialog>
#include <QFileDialog>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QLayout>
#include <QtConcurrent>
namespace {
// Swift's suggestion: the project's name, else Untitled.
QString suggested(const std::optional<QString> &project, const QString &suffix)
{
return (project ? ProjectTab::nameWithoutSuffix(*project) : QStringLiteral("Untitled")) + suffix;
}
}
// Swift's NSSavePanel for one type, whose suffix a name needs.
void ProjectController::exportPanel(const QString &title, const QString &filter, const QStringList &suffixes, const QString &name, const QString &failure,
std::function<void(std::optional<QString>)> then)
{
auto *panel = new QFileDialog(window, title);
panel->setAttribute(Qt::WA_DeleteOnClose);
panel->setAcceptMode(QFileDialog::AcceptSave);
panel->setNameFilter(filter);
panel->setDefaultSuffix(suffixes.first());
panel->selectFile(name);
// Moved, never copied: `then` may hold a whole snapshot.
connect(panel, &QDialog::finished, this, [this, panel, suffixes, failure, then = std::move(then)](int result) mutable {
if (result != QDialog::Accepted) {
then(std::nullopt);
return;
}
const QString chosen = panel->selectedFiles().value(0);
if (suffixes.contains(QFileInfo(chosen).suffix().toLower())) {
then(chosen);
return;
}
// The panel never saw this name: nothing there is replaced.
const QFileInfo renamed(chosen + QLatin1Char('.') + suffixes.first());
if (renamed.exists() || renamed.isSymLink()) {
showError(failure, QStringLiteral("“%1” already exists. Choose another name.").arg(renamed.fileName()),
[then = std::move(then)]() mutable { then(std::nullopt); });
return;
}
then(renamed.filePath());
});
panel->open();
}
// Encoding and the disk block: off the UI thread.
void ProjectController::exportWork(std::function<void()> work, const QString &failure, std::function<void()> end)
try {
auto *watcher = new QFutureWatcher<std::optional<QString>>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, failure, end] {
watcher->deleteLater();
if (const std::optional<QString> message = watcher->result()) {
showError(failure, *message, end);
return;
}
end();
});
watcher->setFuture(QtConcurrent::run([work = std::move(work)]() -> std::optional<QString> {
try {
work();
return std::nullopt;
} catch (const std::runtime_error &error) {
return QString::fromUtf8(error.what());
}
}));
} catch (const std::bad_alloc &) {
showError(failure, QString::fromUtf8(ExportError(ExportError::Kind::render).what()), end);
}
void ProjectController::exportPNG(std::function<void()> done)
{
if (!session.document() || !begin()) {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
return;
}
const auto end = [this, done] {
session.setIsProjectBusy(false);
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
};
const QString failure = QStringLiteral("Couldn’t export PNG");
std::optional<ProjectSnapshot> snapshot;
try {
snapshot = session.projectSnapshot();
} catch (const std::bad_alloc &) {
showError(failure, QString::fromUtf8(ExportError(ExportError::Kind::render).what()), end);
return;
}
exportPanel(QStringLiteral("Export PNG"), QStringLiteral("PNG image (*.png)"), {QStringLiteral("png")}, suggested(session.projectPath(), QStringLiteral(".png")),
failure, [this, snapshot = std::move(snapshot.value()), failure, end](std::optional<QString> path) {
if (!path) {
end();
return;
}
// The snapshot's copy may find no memory.
try {
exportWork([snapshot, path = *path] { ImageExporter::exportPNG(snapshot, path); }, failure, end);
} catch (const std::bad_alloc &) {
showError(failure, QString::fromUtf8(ExportError(ExportError::Kind::render).what()), end);
}
});
}
void ProjectController::exportJPEG(std::function<void()> done)
{
if (!window || !session.document() || !begin()) {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
return;
}
const auto end = [this, done] {
session.setIsProjectBusy(false);
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
};
const QString failure = QStringLiteral("Couldn’t export JPEG");
const QString render = QString::fromUtf8(ExportError(ExportError::Kind::render).what());
std::optional<ProjectSnapshot> snapshot;
try {
snapshot = session.projectSnapshot();
} catch (const std::bad_alloc &) {
showError(failure, render, end);
return;
}
auto *watcher = new QFutureWatcher<Rendered>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, failure, end] {
watcher->deleteLater();
Rendered rendered = watcher->future().takeResult();
if (!rendered.raster) {
showError(failure, rendered.failure, end);
return;
}
QDialog *dialog = sheet(QStringLiteral("Export JPEG"));
auto chosen = std::make_shared<std::optional<QByteArray>>();
dialog->layout()->addWidget(new JPEGExportSheet(std::move(*rendered.raster), [dialog, chosen](std::optional<QByteArray> data) {
*chosen = std::move(data);
dialog->done(*chosen ? QDialog::Accepted : QDialog::Rejected);
}, dialog));
connect(dialog, &QDialog::finished, this, [this, chosen, failure, end] {
if (!*chosen) {
end();
return;
}
exportPanel(QStringLiteral("Export JPEG"), QStringLiteral("JPEG image (*.jpeg *.jpg *.jpe)"),
{QStringLiteral("jpeg"), QStringLiteral("jpg"), QStringLiteral("jpe")}, suggested(session.projectPath(), QStringLiteral(".jpg")), failure,
[this, data = **chosen, failure, end](std::optional<QString> path) {
if (!path) {
end();
return;
}
exportWork([data, path = *path] { ImageExporter::write(data, path); }, failure, end);
});
});
dialog->open();
});
watcher->setFuture(QtConcurrent::run([snapshot = std::move(snapshot.value())]() -> Rendered {
try {
return {ImageExporter::render(snapshot), QString()};
} catch (const ExportError &error) {
return {std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
+118
View File
@@ -0,0 +1,118 @@
#include "IO/CanvasResizer.h"
#include "IO/ImageExporter.h"
#include "IO/ImageResizer.h"
#include "IO/ProjectController.h"
#include "UI/CanvasSizeSheet.h"
#include "UI/ImageSizeSheet.h"
#include <QDialog>
#include <QFutureWatcher>
#include <QVBoxLayout>
#include <QtConcurrent>
QDialog *ProjectController::sheet(const QString &title)
{
auto *dialog = new QDialog(window);
dialog->setAttribute(Qt::WA_DeleteOnClose);
dialog->setWindowTitle(title);
auto *layout = new QVBoxLayout(dialog);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSizeConstraint(QLayout::SetFixedSize);
return dialog;
}
void ProjectController::canvasSize(std::function<void()> done)
{
if (!window || !session.document() || !begin()) {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
return;
}
QDialog *dialog = sheet(QStringLiteral("Canvas Size"));
auto chosen = std::make_shared<std::optional<CanvasSizeOptions>>();
dialog->layout()->addWidget(new CanvasSizeSheet(session.document().value(), session.foregroundColor(), session.backgroundColor(),
[dialog, chosen](std::optional<CanvasSizeOptions> options) {
*chosen = options;
dialog->done(options ? QDialog::Accepted : QDialog::Rejected);
}, dialog));
connect(dialog, &QDialog::finished, this, [this, chosen, done] {
const std::optional<CanvasSizeOptions> options = *chosen;
resizeProject(options ? [options = *options](const ProjectSnapshot &snapshot) { return CanvasResizer::resize(snapshot, options); }
: std::function<ProjectSnapshot(const ProjectSnapshot &)>(),
[this](const ProjectSnapshot &resized) { session.applyDocumentSize(resized, QStringLiteral("Canvas Size")); },
QStringLiteral("Couldn’t change canvas size"), done);
});
dialog->open();
}
void ProjectController::imageSize(std::function<void()> done)
{
if (!window || !session.document() || !begin()) {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
return;
}
QDialog *dialog = sheet(QStringLiteral("Image Size"));
auto chosen = std::make_shared<std::optional<ImageSizeOptions>>();
dialog->layout()->addWidget(new ImageSizeSheet(session.document().value(), [dialog, chosen](std::optional<ImageSizeOptions> options) {
*chosen = options;
dialog->done(options ? QDialog::Accepted : QDialog::Rejected);
}, dialog));
connect(dialog, &QDialog::finished, this, [this, chosen, done] {
const std::optional<ImageSizeOptions> options = *chosen;
resizeProject(options ? [options = *options](const ProjectSnapshot &snapshot) { return ImageResizer::resize(snapshot, options); }
: std::function<ProjectSnapshot(const ProjectSnapshot &)>(),
[this](const ProjectSnapshot &resized) { session.applyImageSize(resized); }, QStringLiteral("Couldn’t resize the image"), done);
});
dialog->open();
}
// Cancelled, nothing runs; running out of memory fails a render.
void ProjectController::resizeProject(std::function<ProjectSnapshot(const ProjectSnapshot &)> resize, std::function<void(const ProjectSnapshot &)> land,
const QString &failure, std::function<void()> done)
{
const auto end = [this, done] {
session.setIsProjectBusy(false);
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
};
const auto explain = [this, failure, end](const QString &message) { showError(failure, message, end); };
const QString render = QString::fromUtf8(ExportError(ExportError::Kind::render).what());
// Taken and started before anything changes; running out explains.
std::optional<ProjectSnapshot> snapshot;
QFutureWatcher<Resized> *watcher = nullptr;
try {
if (resize)
snapshot = session.projectSnapshot();
if (snapshot) {
watcher = new QFutureWatcher<Resized>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [watcher, land, explain, end, render] {
watcher->deleteLater();
try {
const Resized result = watcher->future().takeResult();
if (!result.snapshot) {
explain(result.failure);
return;
}
land(*result.snapshot);
} catch (const std::bad_alloc &) {
explain(render);
return;
}
end();
});
watcher->setFuture(QtConcurrent::run([snapshot = std::move(*snapshot), resize]() -> Resized {
try {
return {resize(snapshot), QString()};
} catch (const std::runtime_error &error) {
return {std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
} catch (const std::bad_alloc &) {
delete watcher;
explain(render);
return;
}
if (!snapshot)
end();
}
+368
View File
@@ -0,0 +1,368 @@
#include "IO/ProjectController.h"
#include "Document/ProjectWorkspace.h"
#include "IO/ProjectStore.h"
#include "Logging.h"
#include <QFileDialog>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QMessageBox>
#include <QPushButton>
#include <QtConcurrent>
bool ProjectController::isProject(const QUrl &url)
{
return QFileInfo(url.adjusted(QUrl::StripTrailingSlash).path()).suffix().toLower() == QLatin1String("comp");
}
bool ProjectController::samePlace(const std::optional<QString> &path, const QString &other)
{
// Nothing there resolves to nothing: compare the names then.
const auto resolved = [](const QString &place) {
const QString canonical = QFileInfo(place).canonicalFilePath();
return canonical.isEmpty() ? QFileInfo(place).absoluteFilePath() : canonical;
};
return path && resolved(*path) == resolved(other);
}
ProjectController::ProjectController(EditorSession &session) : session(session) {}
bool ProjectController::canStart() const
{
return session.canStartProjectOperation() && !(workspace && workspace->isManaging());
}
std::optional<QUuid> ProjectController::ownTab() const
{
if (!workspace)
return std::nullopt;
for (const std::shared_ptr<ProjectTab> &tab : workspace->tabs()) {
if (&tab->controller == this)
return tab->id;
}
return std::nullopt;
}
bool ProjectController::begin()
{
if (!session.canStartProjectOperation())
return false;
session.cancelCrop();
session.commitTransform();
session.setIsProjectBusy(true);
return true;
}
void ProjectController::finish(const std::function<void(bool)> &done, bool value)
{
// The caller goes on first, like a resumed Swift task.
if (done)
QMetaObject::invokeMethod(this, [done, value] { done(value); }, Qt::QueuedConnection);
}
void ProjectController::save(bool asNew, std::function<void(bool)> done)
{
if (!session.document() || !begin()) {
finish(done, false);
return;
}
saveCurrent(asNew, [this, done](bool saved) {
session.setIsProjectBusy(false);
finish(done, saved);
});
}
void ProjectController::saveCurrent(bool asNew, std::function<void(bool)> then)
{
const std::optional<ProjectSnapshot> snapshot = session.projectSnapshot();
if (!snapshot) {
then(true);
return;
}
if (!asNew && session.projectPath()) {
write(*snapshot, *session.projectPath(), then);
return;
}
auto *panel = new QFileDialog(window, asNew ? QStringLiteral("Save Project As") : QStringLiteral("Save Project"));
panel->setAttribute(Qt::WA_DeleteOnClose);
panel->setAcceptMode(QFileDialog::AcceptSave);
panel->setDefaultSuffix(QStringLiteral("comp"));
panel->selectFile(session.projectPath() ? QFileInfo(*session.projectPath()).fileName() : QStringLiteral("Untitled.comp"));
connect(panel, &QDialog::finished, this, [this, panel, snapshot = *snapshot, then](int result) {
if (result != QDialog::Accepted) {
then(false);
return;
}
// The panel only suggests the suffix; a project needs it.
const QString chosen = panel->selectedFiles().value(0);
if (isProject(QUrl::fromLocalFile(chosen))) {
write(snapshot, chosen, then);
return;
}
// The panel never saw this name: nothing there is replaced.
const QFileInfo renamed(chosen + QStringLiteral(".comp"));
// A link that leads nowhere is still in the way.
if (renamed.exists() || renamed.isSymLink()) {
showError(QStringLiteral("Couldn’t save the project"),
QStringLiteral("“%1” already exists. Choose another name.").arg(renamed.fileName()), [then] { then(false); });
return;
}
write(snapshot, renamed.filePath(), then);
});
panel->open();
}
void ProjectController::write(const ProjectSnapshot &snapshot, const QString &destination, std::function<void(bool)> then)
{
auto *watcher = new QFutureWatcher<std::optional<QString>>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, destination, then] {
watcher->deleteLater();
if (const std::optional<QString> failure = watcher->result()) {
showError(QStringLiteral("Couldn’t save the project"), *failure, [then] { then(false); });
return;
}
session.setProjectPath(destination);
session.history.markSaved();
m_saveGeneration += 1;
then(true);
});
// The store blocks on the disk: off the UI thread.
watcher->setFuture(QtConcurrent::run([snapshot, destination]() -> std::optional<QString> {
try {
ProjectStore::save(snapshot, destination);
return std::nullopt;
} catch (const std::runtime_error &error) {
return QString::fromUtf8(error.what());
}
}));
}
void ProjectController::load(const QString &path, std::function<void(Loaded)> then)
{
auto *watcher = new QFutureWatcher<Loaded>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [watcher, then] {
watcher->deleteLater();
then(watcher->result());
});
watcher->setFuture(QtConcurrent::run([path]() -> Loaded {
try {
return {ProjectStore::load(path), QString()};
} catch (const std::runtime_error &error) {
return {std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
void ProjectController::open(std::optional<QString> path, std::function<void(bool)> done)
{
if (workspace) {
workspace->open(path, done);
return;
}
if (!begin()) {
finish(done, false);
return;
}
const auto end = [this, done](bool opened) {
session.setIsProjectBusy(false);
finish(done, opened);
};
const auto refuse = [this, end](const QString &failure) {
showError(QStringLiteral("Couldn’t open the project"), failure, [end] { end(false); });
};
const auto read = [this, end, refuse](QString source) {
// A folder's trailing slash would leave it without a name.
while (source.endsWith(QLatin1Char('/')))
source.chop(1);
// Validated first: a corrupt project never discards the live document.
load(source, [this, end, refuse, source](const Loaded &first) {
if (!first.snapshot) {
refuse(first.failure);
return;
}
const int previousSave = m_saveGeneration;
confirmReplacement([this, end, refuse, source, first, previousSave](bool proceed) {
if (!proceed) {
end(false);
return;
}
// Saving in the confirmation can replace the opened file.
if (m_saveGeneration == previousSave || !samePlace(session.projectPath(), source)) {
session.installProject(*first.snapshot, source);
end(true);
return;
}
load(source, [this, end, refuse, source](const Loaded &again) {
if (!again.snapshot) {
refuse(again.failure);
return;
}
session.installProject(*again.snapshot, source);
end(true);
});
});
});
};
if (path) {
read(*path);
return;
}
// A project is a folder here: one a dialog.
auto *panel = new QFileDialog(window, QStringLiteral("Open Project"));
panel->setAttribute(Qt::WA_DeleteOnClose);
panel->setFileMode(QFileDialog::Directory);
connect(panel, &QDialog::finished, this, [panel, read, end](int result) {
if (result != QDialog::Accepted) {
end(false);
return;
}
read(panel->selectedFiles().value(0));
});
panel->open();
}
void ProjectController::newCanvas()
{
if (workspace) {
workspace->newCanvas();
return;
}
if (!begin())
return;
confirmReplacement([this](bool proceed) {
session.setIsProjectBusy(false);
if (proceed)
session.clearProject();
});
}
void ProjectController::close(QWidget *closing)
{
if (const std::optional<QUuid> tab = ownTab()) {
workspace->close(*tab);
return;
}
if (!begin())
return;
confirmReplacement([this, closing = QPointer<QWidget>(closing)](bool proceed) {
session.setIsProjectBusy(false);
if (!proceed)
return;
session.clearProject();
if (closing)
closing->close();
});
}
void ProjectController::confirmQuit(std::function<void(bool)> done)
{
if (!begin()) {
finish(done, false);
return;
}
confirmReplacement([this, done](bool proceed) {
session.setIsProjectBusy(false);
finish(done, proceed);
});
}
void ProjectController::confirmReplacement(std::function<void(bool)> then)
{
if (!session.isModified() || !session.document()) {
then(true);
return;
}
const QString name = session.projectPath() ? QFileInfo(*session.projectPath()).fileName() : QStringLiteral("Untitled");
auto *alert = new QMessageBox(window);
alert->setAttribute(Qt::WA_DeleteOnClose);
alert->setIcon(QMessageBox::Warning);
alert->setText(QStringLiteral("Save changes to %1?").arg(name));
alert->setInformativeText(QStringLiteral("Your changes will be lost if you don’t save them."));
const QPushButton *save = alert->addButton(QStringLiteral("Save"), QMessageBox::AcceptRole);
alert->addButton(QStringLiteral("Cancel"), QMessageBox::RejectRole);
const QPushButton *discard = alert->addButton(QStringLiteral("Don’t Save"), QMessageBox::DestructiveRole);
connect(alert, &QDialog::finished, this, [this, alert, save, discard, then] {
if (alert->clickedButton() == save)
saveCurrent(false, then);
else
then(alert->clickedButton() == discard);
});
alert->open();
}
void ProjectController::showError(const QString &title, const QString &message, std::function<void()> then)
{
qCWarning(lcIO).noquote() << title + QStringLiteral(": ") + message;
auto *alert = new QMessageBox(window);
alert->setAttribute(Qt::WA_DeleteOnClose);
alert->setIcon(QMessageBox::Warning);
alert->setText(title);
alert->setInformativeText(message);
alert->addButton(QStringLiteral("OK"), QMessageBox::AcceptRole);
connect(alert, &QDialog::finished, this, std::move(then));
alert->open();
}
void ProjectController::receive(const QList<QUrl> &urls, std::optional<QPointF> point, std::function<void()> done)
{
if (const std::optional<QUuid> tab = ownTab()) {
workspace->receive(urls, tab, point, done);
return;
}
if (urls.isEmpty()) {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
return;
}
m_incoming.push_back({urls, point, std::move(done)});
if (m_processing)
return;
m_processing = true;
QMetaObject::invokeMethod(this, &ProjectController::drainIncoming, Qt::QueuedConnection);
}
void ProjectController::drainIncoming()
{
if (m_incoming.empty()) {
m_processing = false;
return;
}
const Incoming request = m_incoming.front();
m_incoming.pop_front();
// The session keeps this waiter, perhaps longer than the controller.
session.waitForFileRequest([self = QPointer<ProjectController>(this), request] {
if (self)
self->handle(request);
});
}
void ProjectController::handle(const Incoming &request)
{
QList<QUrl> projects, images;
for (const QUrl &url : request.urls)
(isProject(url) ? projects : images) << url;
const auto next = [this, done = request.done] {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
drainIncoming();
};
if (projects.size() > 1) {
showError(QStringLiteral("Open one project at a time"), QString::fromUtf8(ProjectError(ProjectError::Kind::invalid).what()), next);
return;
}
// Beside a project the images go to the centre.
const auto bringIn = [this, images, next, point = projects.isEmpty() ? request.point : std::nullopt](bool proceed) {
if (!proceed) {
next();
return;
}
// The session keeps this callback as well.
session.importImages(images, point, [self = QPointer<ProjectController>(this), next] {
if (self)
next();
});
};
if (projects.isEmpty())
bringIn(true);
else
open(projects.constFirst().toLocalFile(), bringIn);
}
+84
View File
@@ -0,0 +1,84 @@
#pragma once
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include <QList>
#include <QPointer>
#include <QUrl>
#include <QWidget>
#include <deque>
#include <functional>
#include <optional>
class ProjectWorkspace;
class QDialog;
// One project's file operations: save, open, close, drops.
class ProjectController : public QObject {
Q_OBJECT
public:
explicit ProjectController(EditorSession &session);
// Its owner declares the session first: it outlives this.
EditorSession &session;
// Dialogs open over it; without one they stand alone.
QPointer<QWidget> window;
// With one, opening and closing are the workspace's to do.
QPointer<ProjectWorkspace> workspace;
static bool isProject(const QUrl &url);
// One place on disk, whatever links lead to it.
static bool samePlace(const std::optional<QString> &path, const QString &other);
bool canStart() const;
// Each `done` runs from the event loop, never inline.
void save(bool asNew = false, std::function<void(bool)> done = {});
void open(std::optional<QString> path = std::nullopt, std::function<void(bool)> done = {});
void newCanvas();
void close(QWidget *closing);
void confirmQuit(std::function<void(bool)> done);
void receive(const QList<QUrl> &urls, std::optional<QPointF> point = std::nullopt, std::function<void()> done = {});
// Swift's sheets over the window; resizes off the UI thread.
void canvasSize(std::function<void()> done = {});
void imageSize(std::function<void()> done = {});
void exportPNG(std::function<void()> done = {});
void exportJPEG(std::function<void()> done = {});
private:
struct Incoming {
QList<QUrl> urls;
std::optional<QPointF> point;
std::function<void()> done;
};
struct Loaded {
std::optional<ProjectSnapshot> snapshot;
QString failure;
};
using Resized = Loaded;
struct Rendered {
std::optional<ExportRaster> raster;
QString failure;
};
bool begin();
void finish(const std::function<void(bool)> &done, bool value);
void saveCurrent(bool asNew, std::function<void(bool)> then);
void write(const ProjectSnapshot &snapshot, const QString &destination, std::function<void(bool)> then);
void load(const QString &path, std::function<void(Loaded)> then);
void confirmReplacement(std::function<void(bool)> then);
void showError(const QString &title, const QString &message, std::function<void()> then);
// Swift's beginSheet: window-modal over the window; Escape cancels.
QDialog *sheet(const QString &title);
// Off the UI thread, then landed or explained; freed after.
void resizeProject(std::function<ProjectSnapshot(const ProjectSnapshot &)> resize, std::function<void(const ProjectSnapshot &)> land,
const QString &failure, std::function<void()> done);
void exportPanel(const QString &title, const QString &filter, const QStringList &suffixes, const QString &name, const QString &failure,
std::function<void(std::optional<QString>)> then);
void exportWork(std::function<void()> work, const QString &failure, std::function<void()> end);
void drainIncoming();
void handle(const Incoming &request);
std::optional<QUuid> ownTab() const;
int m_saveGeneration = 0;
std::deque<Incoming> m_incoming;
bool m_processing = false;
};
+216
View File
@@ -0,0 +1,216 @@
#include "IO/ProjectStore+Json.h"
#include <QJsonArray>
#include <limits>
// LayerAdjustment's synthesized Codable: every key but the optionals.
namespace {
using namespace ManifestJson;
QJsonArray array(const QJsonValue &value)
{
if (!value.isArray())
refuse();
return value.toArray();
}
template <typename Kind, size_t count> Kind kindNamed(const QJsonValue &value, const std::array<Kind, count> &kinds)
{
const QString text = string(value);
for (const Kind kind : kinds) {
if (rawValue(kind) == text)
return kind;
}
refuse();
}
// ColorRange-keyed: key, value in turn; a missing value refuses.
template <typename Value, typename Decode> std::map<ColorRange, Value> keyed(const QJsonValue &value, Decode decode)
{
const QJsonArray pairs = array(value);
std::map<ColorRange, Value> result;
for (qsizetype index = 0; index < pairs.size(); index += 2)
result.insert_or_assign(kindNamed(pairs.at(index), allColorRanges), decode(pairs.at(index + 1)));
return result;
}
template <typename Value, typename Encode> QJsonArray keyed(const std::map<ColorRange, Value> &values, Encode encode)
{
QJsonArray pairs;
for (const auto &[range, value] : values) {
pairs.append(rawValue(range));
pairs.append(encode(value));
}
return pairs;
}
RangeAdjustment rangeAdjustment(const QJsonValue &value)
{
const QJsonObject object = value.toObject();
return {number(object.value("hue")), number(object.value("saturation")), number(object.value("lightness"))};
}
HueBand band(const QJsonValue &value)
{
const QJsonObject object = value.toObject();
return {number(object.value("falloffStart")), number(object.value("rangeStart")), number(object.value("rangeEnd")),
number(object.value("falloffEnd"))};
}
HueSaturationSettings hueSaturation(const QJsonValue &value)
{
const QJsonObject object = value.toObject();
HueSaturationSettings settings;
settings.range = kindNamed(object.value("range"), allColorRanges);
settings.colorize = boolean(object.value("colorize"));
settings.invertRange = boolean(object.value("invertRange"));
settings.adjustments = keyed<RangeAdjustment>(object.value("adjustments"), rangeAdjustment);
settings.bands = keyed<HueBand>(object.value("bands"), band);
return settings;
}
LevelsSettings levels(const QJsonValue &value)
{
const QJsonObject object = value.toObject();
const QJsonArray ranges = array(object.value("ranges"));
// Swift decodes any count; isValid refuses all but four.
if (ranges.size() != 4)
refuse();
LevelsSettings settings;
settings.channel = kindNamed(object.value("channel"), allLevelsChannels);
for (qsizetype index = 0; index < 4; ++index) {
const QJsonObject range = ranges.at(index).toObject();
settings.ranges[size_t(index)] = {number(range.value("black")), number(range.value("gamma")), number(range.value("white")),
number(range.value("outputBlack")), number(range.value("outputWhite"))};
}
return settings;
}
CurvesSettings curves(const QJsonValue &value)
{
const QJsonObject object = value.toObject();
CurvesSettings settings;
settings.channel = kindNamed(object.value("channel"), allLevelsChannels);
settings.channels.clear();
for (const QJsonValue &channel : array(object.value("channels"))) {
std::vector<CurvePoint> points;
for (const QJsonValue &point : array(channel))
points.push_back({number(point.toObject().value("x")), number(point.toObject().value("y"))});
settings.channels.push_back(points);
}
return settings;
}
AdjustmentColor colour(const QJsonValue &value)
{
const QJsonObject object = value.toObject();
return {number(object.value("red")), number(object.value("green")), number(object.value("blue"))};
}
ExposureSettings exposure(const QJsonValue &value)
{
const QJsonObject object = value.toObject();
return {.exposure = number(object.value("exposure")), .offset = number(object.value("offset")), .gamma = number(object.value("gamma"))};
}
GradientMapSettings gradientMap(const QJsonValue &value)
{
const QJsonObject object = value.toObject();
return {colour(object.value("shadows")), colour(object.value("highlights")), boolean(object.value("reversed"))};
}
GrainSettings grain(const QJsonValue &value)
{
const QJsonObject object = value.toObject();
// Swift's UInt32 refuses what it cannot hold.
const qint64 seed = integer(object.value("seed"));
if (seed < 0 || seed > std::numeric_limits<quint32>::max())
refuse();
return {.amount = number(object.value("amount")), .size = number(object.value("size")), .roughness = number(object.value("roughness")),
.seed = quint32(seed)};
}
QJsonObject encoded(const AdjustmentColor &colour)
{
return {{"red", colour.red}, {"green", colour.green}, {"blue", colour.blue}};
}
QJsonObject encoded(const HueSaturationSettings &settings)
{
return {{"range", rawValue(settings.range)},
{"colorize", settings.colorize},
{"invertRange", settings.invertRange},
{"adjustments", keyed(settings.adjustments,
[](const RangeAdjustment &values) {
return QJsonObject{{"hue", values.hue}, {"saturation", values.saturation}, {"lightness", values.lightness}};
})},
{"bands", keyed(settings.bands, [](const HueBand &band) {
return QJsonObject{{"falloffStart", band.falloffStart}, {"rangeStart", band.rangeStart}, {"rangeEnd", band.rangeEnd},
{"falloffEnd", band.falloffEnd}};
})}};
}
QJsonObject encoded(const LevelsSettings &settings)
{
QJsonArray ranges;
for (const LevelRange &range : settings.ranges)
ranges.append(QJsonObject{{"black", range.black}, {"gamma", range.gamma}, {"white", range.white}, {"outputBlack", range.outputBlack},
{"outputWhite", range.outputWhite}});
return {{"channel", rawValue(settings.channel)}, {"ranges", ranges}};
}
QJsonObject encoded(const CurvesSettings &settings)
{
QJsonArray channels;
for (const std::vector<CurvePoint> &points : settings.channels) {
QJsonArray channel;
for (const CurvePoint &point : points)
channel.append(QJsonObject{{"x", point.x}, {"y", point.y}});
channels.append(channel);
}
return {{"channel", rawValue(settings.channel)}, {"channels", channels}};
}
}
LayerAdjustment ManifestJson::adjustment(const QJsonValue &value)
{
const QJsonObject object = value.toObject();
return {.kind = kindNamed(object.value("kind"), allAdjustmentKinds),
.hue = number(object.value("hue")),
.saturation = number(object.value("saturation")),
.lightness = number(object.value("lightness")),
.colorize = boolean(object.value("colorize")),
.hsvSettings = optional(object, "hsvSettings", hueSaturation),
.levels = levels(object.value("levels")),
.curves = curves(object.value("curves")),
.exposureSettings = optional(object, "exposureSettings", exposure),
.gradientMapSettings = optional(object, "gradientMapSettings", gradientMap),
.grainSettings = optional(object, "grainSettings", grain)};
}
QJsonObject ManifestJson::encoded(const LayerAdjustment &adjustment)
{
QJsonObject object{{"kind", rawValue(adjustment.kind)},
{"hue", adjustment.hue},
{"saturation", adjustment.saturation},
{"lightness", adjustment.lightness},
{"colorize", adjustment.colorize},
{"levels", ::encoded(adjustment.levels)},
{"curves", ::encoded(adjustment.curves)}};
if (adjustment.hsvSettings)
object.insert("hsvSettings", ::encoded(*adjustment.hsvSettings));
if (adjustment.exposureSettings) {
const ExposureSettings &exposure = *adjustment.exposureSettings;
object.insert("exposureSettings", QJsonObject{{"exposure", exposure.exposure}, {"offset", exposure.offset}, {"gamma", exposure.gamma}});
}
if (adjustment.gradientMapSettings) {
const GradientMapSettings &map = *adjustment.gradientMapSettings;
object.insert("gradientMapSettings", QJsonObject{{"shadows", ::encoded(map.shadows)}, {"highlights", ::encoded(map.highlights)},
{"reversed", map.reversed}});
}
if (adjustment.grainSettings) {
const GrainSettings &grain = *adjustment.grainSettings;
object.insert("grainSettings", QJsonObject{{"amount", grain.amount}, {"size", grain.size}, {"roughness", grain.roughness},
{"seed", qint64(grain.seed)}});
}
return object;
}
+93
View File
@@ -0,0 +1,93 @@
#include "IO/ProjectStore+Json.h"
// LayerEffects' synthesized Codable: every key but `enabled`.
namespace {
using namespace ManifestJson;
QJsonObject object(const QJsonValue &value)
{
if (!value.isObject())
refuse();
return value.toObject();
}
StrokeEffect stroke(const QJsonValue &value)
{
const QJsonObject keys = object(value);
return {optional(keys, "enabled", boolean), number(keys.value("size")), number(keys.value("red")), number(keys.value("green")),
number(keys.value("blue")), number(keys.value("opacity")), boolean(keys.value("inside"))};
}
ShadowEffect shadow(const QJsonValue &value)
{
const QJsonObject keys = object(value);
return {optional(keys, "enabled", boolean), number(keys.value("angle")), number(keys.value("distance")), number(keys.value("blur")),
number(keys.value("red")), number(keys.value("green")), number(keys.value("blue")), number(keys.value("opacity"))};
}
ColorOverlayEffect colorOverlay(const QJsonValue &value)
{
const QJsonObject keys = object(value);
return {optional(keys, "enabled", boolean), number(keys.value("red")), number(keys.value("green")), number(keys.value("blue")),
number(keys.value("opacity"))};
}
InnerShadowEffect innerShadow(const QJsonValue &value)
{
const QJsonObject keys = object(value);
return {optional(keys, "enabled", boolean), number(keys.value("angle")), number(keys.value("distance")), number(keys.value("blur")),
number(keys.value("red")), number(keys.value("green")), number(keys.value("blue")), number(keys.value("opacity"))};
}
void colour(QJsonObject &keys, double red, double green, double blue)
{
keys.insert("red", red);
keys.insert("green", green);
keys.insert("blue", blue);
}
}
LayerEffects ManifestJson::effects(const QJsonValue &value)
{
const QJsonObject keys = object(value);
return {optional(keys, "stroke", stroke), optional(keys, "shadow", shadow), optional(keys, "colorOverlay", colorOverlay),
optional(keys, "innerShadow", innerShadow)};
}
QJsonObject ManifestJson::encoded(const LayerEffects &effects)
{
QJsonObject keys;
if (effects.stroke) {
const StrokeEffect &stroke = *effects.stroke;
QJsonObject object{{"size", stroke.size}, {"opacity", stroke.opacity}, {"inside", stroke.inside}};
colour(object, stroke.red, stroke.green, stroke.blue);
if (stroke.enabled)
object.insert("enabled", *stroke.enabled);
keys.insert("stroke", object);
}
if (effects.shadow) {
const ShadowEffect &shadow = *effects.shadow;
QJsonObject object{{"angle", shadow.angle}, {"distance", shadow.distance}, {"blur", shadow.blur}, {"opacity", shadow.opacity}};
colour(object, shadow.red, shadow.green, shadow.blue);
if (shadow.enabled)
object.insert("enabled", *shadow.enabled);
keys.insert("shadow", object);
}
if (effects.colorOverlay) {
const ColorOverlayEffect &overlay = *effects.colorOverlay;
QJsonObject object{{"opacity", overlay.opacity}};
colour(object, overlay.red, overlay.green, overlay.blue);
if (overlay.enabled)
object.insert("enabled", *overlay.enabled);
keys.insert("colorOverlay", object);
}
if (effects.innerShadow) {
const InnerShadowEffect &inner = *effects.innerShadow;
QJsonObject object{{"angle", inner.angle}, {"distance", inner.distance}, {"blur", inner.blur}, {"opacity", inner.opacity}};
colour(object, inner.red, inner.green, inner.blue);
if (inner.enabled)
object.insert("enabled", *inner.enabled);
keys.insert("innerShadow", object);
}
return keys;
}
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include "IO/ProjectStore.h"
#include <QJsonObject>
#include <QJsonValue>
// Swift's synthesized Codable by hand: the readers the manifest shares.
namespace ManifestJson {
[[noreturn]] void refuse();
QString string(const QJsonValue &value);
bool boolean(const QJsonValue &value);
double number(const QJsonValue &value);
// Swift's Int: Qt holds a whole 64-bit token exactly.
qint64 integer(const QJsonValue &value);
QUuid uuid(const QJsonValue &value);
// Absent and null both mean nil.
template <typename Decode>
auto optional(const QJsonObject &object, const char *key, Decode decode) -> std::optional<decltype(decode(QJsonValue()))>
{
const QJsonValue value = object.value(QLatin1String(key));
if (value.isUndefined() || value.isNull())
return std::nullopt;
return decode(value);
}
// LayerAdjustment's keys, in ProjectStore+Adjustment.cpp.
LayerAdjustment adjustment(const QJsonValue &value);
QJsonObject encoded(const LayerAdjustment &adjustment);
// LayerEffects' keys, in ProjectStore+Effects.cpp.
LayerEffects effects(const QJsonValue &value);
QJsonObject encoded(const LayerEffects &effects);
}
+242
View File
@@ -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"))};
}
+326
View File
@@ -0,0 +1,326 @@
#include "IO/ProjectStore.h"
#include "Document/LayerGroups.h"
#include "Document/LayerMask.h"
#include "Document/LiveLayerMask.h"
#include "Logging.h"
#include <QBuffer>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QImageReader>
#include <QImageWriter>
#include <QSet>
#include <cerrno>
#include <cmath>
#include <cstring>
#include <functional>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
namespace {
QString description(ProjectError::Kind kind)
{
switch (kind) {
case ProjectError::Kind::invalid:
return QStringLiteral("This is not a valid OmaPhoto project, or its metadata is damaged.");
case ProjectError::Kind::version:
throw std::logic_error("a version error needs its version number");
case ProjectError::Kind::missingImage:
return QStringLiteral("An image inside the project is missing or damaged. The current document has not been replaced.");
case ProjectError::Kind::tooLarge:
return QStringLiteral("This project exceeds the supported canvas, layer, file-size, or 100-megapixel image limit.");
case ProjectError::Kind::encode:
return QStringLiteral("An image could not be saved. The previous project has not been replaced.");
}
throw std::logic_error("unknown ProjectError kind");
}
constexpr qint64 manifestBytes = 4 * 1024 * 1024, assetBytes = 512 * 1024 * 1024;
void validate(const ProjectManifest &manifest)
{
if (manifest.format != QLatin1String("com.compositor.project"))
throw ProjectError(ProjectError::Kind::invalid);
if (manifest.version < 1 || manifest.version > 7)
throw ProjectError::unsupportedVersion(manifest.version);
if (manifest.colorSpace != QLatin1String("sRGB"))
throw ProjectError(ProjectError::Kind::invalid);
// The range refuses NaN and infinity as well.
if (manifest.resolution && !(*manifest.resolution >= 1 && *manifest.resolution <= 9600))
throw ProjectError(ProjectError::Kind::invalid);
if (manifest.width < 1 || manifest.width > 30'000 || manifest.height < 1 || manifest.height > 30'000 || manifest.layers.size() > 10'000)
throw ProjectError(ProjectError::Kind::tooLarge);
for (const ProjectLayerRecord &layer : manifest.layers) {
// Text needs a valid style and pixels to show it.
if (layer.text && (!layer.text->isValid() || !layer.imageFile))
throw ProjectError(ProjectError::Kind::invalid);
// Version 7's adjustments hold settings, never pixels or children.
if (layer.adjustment && (manifest.version < 7 || layer.isGroup == true || layer.imageFile || !layer.adjustment->isValid()))
throw ProjectError(ProjectError::Kind::invalid);
const bool group = layer.isGroup == true;
// Layer masks came with version 4, folder masks with 6.
const bool maskAllowed = !layer.maskFile
|| (manifest.version >= (group ? 6 : 4) && *layer.maskFile == uuidString(layer.id) + QLatin1String(".mask.png"));
const bool placementAllowed = !layer.maskPlacement || (layer.maskPlacement->isValid() && layer.maskFile);
const double opacity = layer.opacity.value_or(1);
const bool plain = opacity == 1 && layer.blendMode.value_or(LayerBlendMode::normal) == LayerBlendMode::normal;
if (!maskAllowed || (layer.maskEnabled && !layer.maskFile) || !placementAllowed || !std::isfinite(opacity) || opacity < 0
|| opacity > 1 || (manifest.version < 3 && !plain) || (group && !plain))
throw ProjectError(ProjectError::Kind::invalid);
}
LayerHierarchy::validate(manifest.layers);
LiveMaskGraph::validate(manifest.layers);
QSet<QUuid> ids;
for (const ProjectLayerRecord &layer : manifest.layers) {
// A child needs a folder, so folders alone mark nesting.
if ((manifest.version < 5 && layer.maskSourceID) || (manifest.version == 1 && layer.isGroup == true))
throw ProjectError(ProjectError::Kind::invalid);
}
for (const ProjectLayerRecord &layer : manifest.layers) {
const bool named = !layer.name.trimmed().isEmpty() && layer.name.toUtf8().size() <= 16'384;
const bool filed = !layer.imageFile || *layer.imageFile == uuidString(layer.id) + QLatin1String(".png");
if (!layer.transform.isValid() || !named || !filed)
throw ProjectError(ProjectError::Kind::invalid);
ids.insert(layer.id);
}
if (manifest.activeLayerID && !ids.contains(*manifest.activeLayerID))
throw ProjectError(ProjectError::Kind::invalid);
}
// Images share 100 million pixels; masks share another.
void checkSize(QSize size, qint64 &used)
{
const qint64 pixels = qint64(size.width()) * size.height();
if (size.width() < 1 || size.width() > 30'000 || size.height() < 1 || size.height() > 30'000 || pixels > 100'000'000 - used)
throw ProjectError(ProjectError::Kind::tooLarge);
used += pixels;
}
// A project's files are plain files inside it, never links.
void checkFile(const QString &file, const QString &package, qint64 maximumBytes, ProjectError::Kind missing)
{
const QFileInfo info(file);
if (!info.exists() && !info.isSymLink())
throw ProjectError(missing);
const QString root = QFileInfo(package).canonicalFilePath() + QLatin1Char('/');
if (!info.canonicalFilePath().startsWith(root))
throw ProjectError(ProjectError::Kind::invalid);
if (!info.isFile() || info.isSymLink() || info.size() > maximumBytes)
throw ProjectError(ProjectError::Kind::tooLarge);
}
QByteArray png(const QImage &image)
{
QByteArray data;
QBuffer buffer(&data);
QImageWriter writer(&buffer, "png");
if (!writer.write(image))
throw ProjectError(ProjectError::Kind::encode);
return data;
}
[[noreturn]] void fail(const QString &what, const QString &path, const QString &reason)
{
const QString message = QStringLiteral("%1 %2: %3").arg(what, path, reason);
qCWarning(lcIO).noquote() << message;
throw std::runtime_error(message.toStdString());
}
// Synced to disk: a swap must never expose empty files.
void writeFile(const QString &path, const QByteArray &data)
{
QFile file(path);
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.flush() || ::fsync(file.handle()) != 0)
fail(QStringLiteral("could not write"), path, file.error() == QFile::NoError ? QString::fromLocal8Bit(std::strerror(errno)) : file.errorString());
}
void syncDirectory(const QString &path)
{
const int descriptor = ::open(QFile::encodeName(path).constData(), O_RDONLY | O_DIRECTORY);
const bool synced = descriptor >= 0 && ::fsync(descriptor) == 0;
const QString reason = QString::fromLocal8Bit(std::strerror(errno));
if (descriptor >= 0)
::close(descriptor);
if (!synced)
fail(QStringLiteral("could not sync"), path, reason);
}
// Puts `staged` where `destination` is; a failed sync undoes it.
void replace(const QString &staged, const QString &destination, const QString &parent, bool &keepSibling)
{
const QString besides = staged + QStringLiteral(".old");
const QByteArray from = QFile::encodeName(staged), to = QFile::encodeName(destination), aside = QFile::encodeName(besides);
const auto moved = [](const QByteArray &source, const QByteArray &target) { return ::rename(source.constData(), target.constData()) == 0; };
const auto exchanged = [&] { return ::renameat2(AT_FDCWD, from.constData(), AT_FDCWD, to.constData(), RENAME_EXCHANGE) == 0; };
// Each way in has its way back.
std::function<bool()> undo;
// Where the old project sits once the new is in.
QString previous;
if (!QFileInfo::exists(destination) && !QFileInfo(destination).isSymLink()) {
if (!moved(from, to))
fail(QStringLiteral("could not move the project to"), destination, QString::fromLocal8Bit(std::strerror(errno)));
undo = [&] { return moved(to, from); };
} else if (exchanged()) {
undo = exchanged;
previous = staged;
} else {
if (errno != EINVAL && errno != ENOSYS && errno != ENOTSUP)
fail(QStringLiteral("could not replace"), destination, QString::fromLocal8Bit(std::strerror(errno)));
// No exchange here: two moves, the old kept until then.
qCWarning(lcIO) << "this file system cannot exchange paths; replacing" << destination << "in two steps";
if (!moved(to, aside))
fail(QStringLiteral("could not replace"), destination, QString::fromLocal8Bit(std::strerror(errno)));
if (!moved(from, to)) {
const QString reason = QString::fromLocal8Bit(std::strerror(errno));
if (!moved(aside, to))
qCCritical(lcIO) << "the previous project is left at" << besides;
fail(QStringLiteral("could not replace"), destination, reason);
}
undo = [&] { return moved(to, from) && moved(aside, to); };
previous = besides;
}
try {
syncDirectory(parent);
} catch (const std::runtime_error &) {
// A way back that fails must delete nothing.
keepSibling = !undo();
const QString stays = previous.isEmpty() ? QStringLiteral("the unsynced project could not be taken from ") + destination
: QStringLiteral("the previous project could not be put back; it stays at ") + previous;
if (keepSibling)
qCCritical(lcIO).noquote() << stays;
throw;
}
// The old project waits at the sibling's path for removal.
if (previous == besides && !moved(aside, from))
qCWarning(lcIO) << "the previous project is left at" << besides;
}
// The pixels and a thumbnail of at most 96 pixels.
ImportedImage asset(const QString &file, bool isMask, const QString &name, qint64 &used)
{
ImageImporter::liftAllocationLimit();
QImageReader reader(file);
const QImage::Format stored = reader.imageFormat();
const bool deep = stored == QImage::Format_RGBA64 || stored == QImage::Format_RGBA64_Premultiplied || stored == QImage::Format_RGBX64
|| stored == QImage::Format_Grayscale16;
if (reader.format() != "png" || !reader.size().isValid() || deep)
throw ProjectError(ProjectError::Kind::missingImage);
checkSize(reader.size(), used);
QImage image = reader.read();
if (!isMask)
image = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
if (image.isNull())
throw ProjectError(ProjectError::Kind::missingImage);
if (isMask && !LayerMask::isValid(image))
throw ProjectError(ProjectError::Kind::invalid);
const int longest = std::max(image.width(), image.height());
const QImage thumbnail = longest <= 96 ? image : image.scaled(image.size().scaled(96, 96, Qt::KeepAspectRatio).expandedTo(QSize(1, 1)),
Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (thumbnail.isNull())
throw ProjectError(ProjectError::Kind::missingImage);
return ImportedImage(image, thumbnail, name);
}
}
ProjectError::ProjectError(Kind kind) : ProjectError(kind, std::nullopt, description(kind)) {}
ProjectError ProjectError::unsupportedVersion(qint64 version)
{
return ProjectError(Kind::version, version,
QStringLiteral("This project uses format version %1. This app supports versions 1–7.").arg(version));
}
ProjectError::ProjectError(Kind kind, std::optional<qint64> version, const QString &description)
: std::runtime_error(description.toStdString()), kind(kind), version(version)
{
}
void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path)
{
validate(snapshot.manifest);
std::vector<std::pair<QString, QByteArray>> files;
qint64 pixels = 0, maskPixels = 0;
for (const ProjectLayerRecord &layer : snapshot.manifest.layers) {
for (const bool isMask : {false, true}) {
const std::optional<QString> &filename = isMask ? layer.maskFile : layer.imageFile;
if (!filename)
continue;
const std::map<QUuid, ImportedImage> &assets = isMask ? snapshot.masks : snapshot.images;
const auto found = assets.find(layer.id);
if (found == assets.end())
throw ProjectError(ProjectError::Kind::missingImage);
const QImage image = found->second.image();
if (isMask && !LayerMask::isValid(image))
throw ProjectError(ProjectError::Kind::invalid);
checkSize(image.size(), isMask ? maskPixels : pixels);
files.push_back({*filename, png(image)});
}
}
const QByteArray metadata = snapshot.manifest.encoded();
if (metadata.size() > manifestBytes)
throw ProjectError(ProjectError::Kind::tooLarge);
// A sibling is written whole, then takes the project's place.
const QFileInfo destination(QDir::cleanPath(path));
const QString staged = destination.absolutePath() + QLatin1String("/.") + destination.fileName() + QLatin1Char('.')
+ uuidString(QUuid::createUuid()) + QLatin1String(".tmp");
// Whatever lies at the sibling's path goes.
struct Staging {
QString path;
bool keep = false;
~Staging()
{
const QFileInfo info(path);
if (!keep && (info.exists() || info.isSymLink())
&& !(info.isDir() && !info.isSymLink() ? QDir(path).removeRecursively() : QFile::remove(path)))
qCWarning(lcIO) << "could not remove" << path;
}
} staging{staged};
if (!QDir().mkdir(staged) || !QDir().mkdir(staged + QLatin1String("/images")))
fail(QStringLiteral("could not create"), staged, QStringLiteral("the folder cannot be made"));
writeFile(staged + QLatin1String("/manifest.json"), metadata);
for (const auto &[filename, data] : files)
writeFile(staged + QLatin1String("/images/") + filename, data);
syncDirectory(staged + QLatin1String("/images"));
syncDirectory(staged);
replace(staged, destination.absoluteFilePath(), destination.absolutePath(), staging.keep);
qCInfo(lcIO) << "saved" << destination.absoluteFilePath() << "with" << snapshot.manifest.layers.size() << "layers and" << files.size() << "images";
}
ProjectSnapshot ProjectStore::load(const QString &path)
{
const QString metadataPath = path + QLatin1String("/manifest.json");
checkFile(metadataPath, path, manifestBytes, ProjectError::Kind::invalid);
QFile metadataFile(metadataPath);
if (!metadataFile.open(QIODevice::ReadOnly))
throw ProjectError(ProjectError::Kind::invalid);
const QByteArray metadata = metadataFile.readAll();
// Qt hides errors after partial data: the byte count tells.
if (metadataFile.error() != QFile::NoError || metadata.size() != metadataFile.size()) {
qCWarning(lcIO) << "could not read" << metadataPath << ":" << metadataFile.errorString();
throw ProjectError(ProjectError::Kind::invalid);
}
// The version comes first: a newer file may not decode.
const auto [format, version] = ProjectManifest::header(metadata);
if (format != QLatin1String("com.compositor.project"))
throw ProjectError(ProjectError::Kind::invalid);
if (version < 1 || version > 7)
throw ProjectError::unsupportedVersion(version);
const ProjectManifest manifest = ProjectManifest::decoded(metadata);
validate(manifest);
ProjectSnapshot snapshot{.manifest = manifest, .images = {}, .masks = {}};
qint64 pixels = 0, maskPixels = 0;
for (const ProjectLayerRecord &layer : manifest.layers) {
for (const bool isMask : {false, true}) {
const std::optional<QString> &filename = isMask ? layer.maskFile : layer.imageFile;
if (!filename)
continue;
const QString file = path + QLatin1String("/images/") + *filename;
checkFile(file, path, assetBytes, ProjectError::Kind::missingImage);
(isMask ? snapshot.masks : snapshot.images).insert({layer.id, asset(file, isMask, layer.name, isMask ? maskPixels : pixels)});
}
}
qCInfo(lcIO) << "loaded" << path << "with" << manifest.layers.size() << "layers and" << snapshot.images.size() + snapshot.masks.size() << "images";
return snapshot;
}
+95
View File
@@ -0,0 +1,95 @@
#pragma once
#include "Document/LayerAdjustment.h"
#include "Document/LayerAppearance.h"
#include "Document/LayerEffects.h"
#include "Document/LayerTransform.h"
#include "Document/ShapeTool.h"
#include "Document/TypeTool.h"
#include "IO/ImageImporter.h"
#include <QByteArray>
#include <QString>
#include <QUuid>
#include <map>
#include <optional>
#include <stdexcept>
#include <utility>
#include <vector>
struct LayerMask;
// Absent optionals stay out of the manifest.
struct ProjectLayerRecord {
QUuid id;
QString name;
bool isVisible;
LayerTransform transform;
std::optional<QString> imageFile;
std::optional<QUuid> parentID = std::nullopt;
std::optional<bool> isGroup = std::nullopt;
std::optional<double> opacity = std::nullopt;
std::optional<LayerBlendMode> blendMode = std::nullopt;
std::optional<QString> maskFile = std::nullopt;
std::optional<bool> maskEnabled = std::nullopt;
std::optional<QUuid> maskSourceID = std::nullopt;
std::optional<LayerAdjustment> adjustment = std::nullopt;
std::optional<LayerTransform> maskPlacement = std::nullopt;
// Absent in older projects, which means linked.
std::optional<bool> maskLinked = std::nullopt;
// A shape layer's shape; older versions keep the pixels alone.
std::optional<LayerShapeStyle> shape = std::nullopt;
// What the layer draws round itself; its pixels stay.
std::optional<LayerEffects> effects = std::nullopt;
std::optional<LayerTextStyle> text = std::nullopt;
};
// A UUID as Swift writes it: upper case, no braces.
QString uuidString(const QUuid &id);
struct ProjectManifest {
QString format = QStringLiteral("com.compositor.project");
// Swift's Int: a wild number must reach validation whole.
qint64 version = 7;
QString colorSpace = QStringLiteral("sRGB");
// Version-1 projects carry none: 72 pixels per inch.
std::optional<double> resolution = std::nullopt;
QUuid documentID;
qint64 width;
qint64 height;
std::optional<QUuid> activeLayerID;
std::vector<ProjectLayerRecord> layers;
// Pretty JSON with sorted keys; absent optionals are left out.
QByteArray encoded() const;
// Throws ProjectError(invalid) on anything Swift's decoder refuses.
static ProjectManifest decoded(const QByteArray &data);
// Format and version alone: newer files may not decode.
static std::pair<QString, qint64> header(const QByteArray &data);
};
struct ProjectSnapshot {
ProjectManifest manifest;
std::map<QUuid, ImportedImage> images;
std::map<QUuid, ImportedImage> masks = {};
// A record's mask, from its mask file and fields.
std::optional<LayerMask> mask(const ProjectLayerRecord &layer) const;
};
class ProjectError : public std::runtime_error {
public:
enum class Kind { invalid, version, missingImage, tooLarge, encode };
explicit ProjectError(Kind kind);
static ProjectError unsupportedVersion(qint64 version);
const Kind kind;
// Set only for Kind::version.
const std::optional<qint64> version;
private:
ProjectError(Kind kind, std::optional<qint64> version, const QString &description);
};
// A .comp project: a directory holding manifest.json and images/.
namespace ProjectStore {
// Replaces the project in one step or leaves it untouched.
void save(const ProjectSnapshot &snapshot, const QString &path);
ProjectSnapshot load(const QString &path);
}