OmaPhoto 1.0.0: the Linux port of Compositor
A layered image editor for Linux and Omarchy, ported from Compositor (github.com/robbietilton/Compositor), whose Swift source is its specification. It reads and writes Compositor's .comp projects. Packages for Ubuntu, Fedora and Arch come from the release workflow; scripts/install/ holds one install script per distribution family, and flake.nix serves NixOS. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
c864304695
564 files changed
+100804
No files matched your search
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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())};
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"))};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user