Photoshop PSD import (Compositor 1.2.0, part one)

This commit is contained in:
雷电芽衣 committed 2026-09-27 04:41:08 -04:00
1 parent 3d56688ce4
commit 2cd0d1b86d
41 files changed
+3912 -31

No files matched your search

+37 -1
View File
@@ -5,6 +5,7 @@
#include "UI/HueSaturationSheet.h"
#include "UI/LevelsSheet.h"
#include <QFileDialog>
#include <QVBoxLayout>
#include <QMessageBox>
void ContentView::synchronizePanels()
@@ -84,7 +85,7 @@ void ContentView::showImporter()
m_importer = new QFileDialog(this);
m_importer->setAttribute(Qt::WA_DeleteOnClose);
m_importer->setFileMode(QFileDialog::ExistingFiles);
m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff)"));
m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd)"));
connect(m_importer, &QDialog::finished, this, [this, panel = m_importer.data()](int result) {
// Ours no longer: the flag must not reject it again.
if (m_importer != panel)
@@ -124,3 +125,38 @@ void ContentView::showAlert(QPointer<QMessageBox> &alert, const QString &title,
});
alert->open();
}
void ContentView::showConversionSheet()
{
const std::optional<PSDConversionRequest> &request = m_session.conversionRequest();
if (!m_session.showsConversionSheet() || !request) {
// Ours no longer, so the close answers nothing.
if (QDialog *shown = std::exchange(m_conversionSheet, nullptr))
shown->reject();
return;
}
if (m_conversionSheet && m_conversionShown == *request)
return;
m_conversionShown = *request;
if (!m_conversionSheet) {
m_conversionSheet = new QDialog(this);
m_conversionSheet->setObjectName(QStringLiteral("conversionSheet"));
m_conversionSheet->setAttribute(Qt::WA_DeleteOnClose);
auto *layout = new QVBoxLayout(m_conversionSheet);
layout->setContentsMargins(0, 0, 0, 0);
// Escape and the close button cancel, as Swift's dismissal.
connect(m_conversionSheet, &QDialog::finished, this, [this, shown = m_conversionSheet.data()] {
if (m_conversionSheet != shown)
return;
m_session.finishConversion(false);
});
}
// Reading gives way to the list: new content.
for (PSDConversionSheet *old : m_conversionSheet->findChildren<PSDConversionSheet *>(Qt::FindDirectChildrenOnly)) {
old->hide();
old->deleteLater();
}
m_conversionSheet->setWindowTitle(request->title);
m_conversionSheet->layout()->addWidget(new PSDConversionSheet(*request, [this](bool confirmed) { m_session.finishConversion(confirmed); }, m_conversionSheet));
m_conversionSheet->open();
}
+1
View File
@@ -398,6 +398,7 @@ void ContentView::synchronize()
m_activity->setText(m_session.showsBusy() ? QStringLiteral("Working…") : m_session.isImporting() ? QStringLiteral("Importing images…")
: hint(m_session.tool(), iconKind(m_session.tool()), m_session.blurMode(), m_session.shapeKind()));
showImporter();
showConversionSheet();
showAlert(m_importAlert, QStringLiteral("Import couldn’t finish"), m_session.importError(), [this] { m_session.setImportError(std::nullopt); });
showAlert(m_brushAlert, QStringLiteral("Couldn’t paint"), m_session.brushError(), [this] { m_session.setBrushError(std::nullopt); });
showAlert(m_cropAlert, QStringLiteral("Couldn’t crop"), m_session.cropError(), [this] { m_session.setCropError(std::nullopt); });
+5
View File
@@ -4,6 +4,7 @@
#include "Rendering/EditorCanvas.h"
#include "UI/FloatingPanel.h"
#include "UI/ToolIcons.h"
#include <QDialog>
#include <QGridLayout>
#include <QLabel>
#include <QPointer>
@@ -51,6 +52,7 @@ private:
void synchronize();
// Swift's onChange of levels: its panel opens and closes.
void synchronizePanels();
void showConversionSheet();
ToolIconKind iconKind(NavigationTool tool) const;
void showHeader(NavigationTool tool);
void showWelcome(bool shown);
@@ -81,6 +83,9 @@ private:
QLabel *const m_activity;
QProgressBar *const m_spinner;
QPointer<QFileDialog> m_importer;
// The Photoshop sheet and the request it shows.
QPointer<QDialog> m_conversionSheet;
std::optional<PSDConversionRequest> m_conversionShown;
QPointer<QMessageBox> m_importAlert;
QPointer<QMessageBox> m_brushAlert;
QPointer<QMessageBox> m_cropAlert;
+160 -1
View File
@@ -1,4 +1,6 @@
#include "Document/EditorSession.h"
#include "Document/ProjectWorkspace.h"
#include "IO/PSD/PSDReader.h"
#include "Logging.h"
#include <QtConcurrent>
@@ -73,7 +75,9 @@ void EditorSession::drainImports()
setImportError(std::exchange(m_importFailures, {}).join("\n\n"));
return;
}
beginEdit(QStringLiteral("Import Images"));
const QList<QUrl> &urls = m_pendingImports.front().urls;
const bool photoshop = std::all_of(urls.begin(), urls.end(), [](const QUrl &url) { return url.isLocalFile() && PSDReader::matches(url.toLocalFile()); });
beginEdit(photoshop ? QStringLiteral("Import Photoshop File") : QStringLiteral("Import Images"));
// Without a canvas the first image makes it.
m_importPoint = m_document ? m_pendingImports.front().point : std::nullopt;
m_importIndex = 0;
@@ -101,6 +105,23 @@ void EditorSession::decodeNext()
}
}
const QUrl url = request.urls[m_importIndex];
if (url.isLocalFile() && PSDReader::matches(url.toLocalFile())) {
beginPSDReading(QStringLiteral("Open “%1”?").arg(url.fileName()), QStringLiteral("Import"));
m_photoshopReader.setFuture(QtConcurrent::run([path = url.toLocalFile(), remaining = 100'000'000 - usedPixels]() -> PhotoshopRead {
try {
PSDDocument document = ImageImporter::loadPhotoshop(path, remaining);
std::map<QUuid, ImportedImage> assets = ImageImporter::photoshopAssets(document);
return {std::move(document), std::move(assets), QString()};
} catch (const ImageImportError &error) {
return {std::nullopt, {}, QString::fromUtf8(error.what())};
} catch (const PSDError &error) {
return {std::nullopt, {}, QString::fromUtf8(error.what())};
} catch (const ExportError &error) {
return {std::nullopt, {}, QString::fromUtf8(error.what())};
}
}));
return;
}
m_decoder.setFuture(QtConcurrent::run([url, remaining = 100'000'000 - usedPixels]() -> Decoded {
try {
if (!url.isLocalFile())
@@ -122,3 +143,141 @@ void EditorSession::finishDecode()
m_importIndex += 1;
decodeNext();
}
void EditorSession::finishPhotoshopRead()
{
const PhotoshopRead read = m_photoshopReader.result();
const QUrl url = m_pendingImports.front().urls[m_importIndex];
const auto next = [this] {
m_importIndex += 1;
decodeNext();
};
// Every image has its asset: making layers cannot fail.
const std::optional<PSDImport> imported = read.document ? std::optional(PSDDocumentBuilder::makeImport(*read.document, read.assets)) : std::nullopt;
if (!imported) {
endPSDReading();
m_importFailures << url.fileName() + ": " + read.failure;
next();
return;
}
const std::vector<PSDConversion> conversions = imported->conversions;
finishPSDReading(conversions, [this, imported = std::move(*imported), url, next](bool confirmed) {
if (confirmed) {
try {
insertPhotoshop(imported, ProjectTab::nameWithoutSuffix(url.toLocalFile()), m_importPoint);
} catch (const std::runtime_error &error) {
m_importFailures << url.fileName() + ": " + QString::fromUtf8(error.what());
}
}
next();
});
}
void EditorSession::beginPSDReading(const QString &title, const QString &confirmTitle)
{
m_conversionCancelled = false;
m_conversionRequest = PSDConversionRequest{title, confirmTitle, {}, true};
m_showsConversionSheet = true;
notify();
}
void EditorSession::finishPSDReading(const std::vector<PSDConversion> &conversions, std::function<void(bool)> answer)
{
if (m_conversionCancelled || conversions.empty()) {
const bool confirmed = !m_conversionCancelled;
endPSDReading();
answer(confirmed);
return;
}
// The sheet waits for Cancel or the confirm button.
m_conversionAnswer = std::move(answer);
m_conversionRequest->conversions = conversions;
m_conversionRequest->isReading = false;
notify();
}
void EditorSession::endPSDReading()
{
if (m_conversionAnswer)
return;
m_showsConversionSheet = false;
m_conversionRequest = std::nullopt;
notify();
}
void EditorSession::finishConversion(bool confirmed)
{
if (!confirmed && m_conversionRequest && m_conversionRequest->isReading)
m_conversionCancelled = true;
m_showsConversionSheet = false;
m_conversionRequest = std::nullopt;
notify();
// The import resumes from the event loop, as a task.
if (const std::function<void(bool)> answer = std::exchange(m_conversionAnswer, {}))
QMetaObject::invokeMethod(this, [answer, confirmed] { answer(confirmed); }, Qt::QueuedConnection);
}
void EditorSession::insertPhotoshop(const PSDImport &imported, const QString &named, std::optional<QPointF> centeredAt)
{
std::vector<ImageLayer> incoming = imported.layers;
const size_t existing = m_document ? m_document->layers.size() : 0;
if (existing + incoming.size() + (m_document ? 1 : 0) > 10'000)
throw ImageImportError(ImageImportError::Kind::tooLarge);
std::vector<ImageLayer> layers = m_document ? m_document->layers : std::vector<ImageLayer>();
std::optional<ImageLayer> group;
if (m_document) {
// An open canvas takes the file as one folder.
group = ImageLayer(named, m_document->size());
group->isGroup = true;
const std::optional<ImageLayer> active = activeLayer();
group->parentID = active && active->isGroup ? m_activeLayerID : active ? active->parentID : std::nullopt;
std::optional<QRectF> box;
for (const ImageLayer &layer : incoming)
if (!layer.isGroup)
box = box ? box->united(QRectF(layer.origin(), layer.size())) : QRectF(layer.origin(), layer.size());
if (centeredAt && box) {
const QPointF shift = *centeredAt - box->center();
for (ImageLayer &layer : incoming)
layer.transform.origin += shift;
}
for (ImageLayer &layer : incoming)
if (!layer.parentID)
layer.parentID = group->id;
layers.push_back(*group);
}
layers.insert(layers.end(), incoming.begin(), incoming.end());
// Folders nested past Swift's limit refuse the file.
std::vector<ProjectLayerRecord> records;
for (const ImageLayer &layer : layers)
records.push_back(layer.hierarchyRecord());
try {
LayerHierarchy::validate(records);
} catch (const ProjectError &) {
qCWarning(lcIO) << "a Photoshop file nests its folders too deep";
throw PSDError(PSDError::Kind::truncated);
}
beginEdit(QStringLiteral("Import Photoshop File"));
if (!group) {
CanvasDocument document(imported.width, imported.height);
document.layers = std::move(layers);
document.resolution = imported.resolution;
m_document = std::move(document);
viewport.fit(m_document->size());
// The topmost root layer, else the topmost layer.
std::optional<QUuid> active = incoming.empty() ? std::nullopt : std::optional(incoming.back().id);
for (size_t index = incoming.size(); index-- > 0;) {
if (!incoming[index].parentID) {
active = incoming[index].id;
break;
}
}
setActiveLayerID(active);
} else {
m_document->layers = std::move(layers);
if (group->parentID)
m_collapsedGroupIDs.remove(*group->parentID);
setActiveLayerID(group->id);
}
endEdit();
qCInfo(lcIO) << "imported a Photoshop file of" << incoming.size() << "layers";
}
+7
View File
@@ -5,6 +5,7 @@
#include "Document/Selection.h"
#include "IO/ImageImporter.h"
#include "IO/ProjectStore.h"
#include "IO/PSD/PSDTypes.h"
#include <QImage>
#include <QPainterPath>
#include <QSet>
@@ -30,6 +31,12 @@ struct SessionJobs {
std::optional<ImportedImage> asset;
QString failure;
};
// A Photoshop file read with its assets, or why not.
struct PhotoshopRead {
std::optional<PSDDocument> document;
std::map<QUuid, ImportedImage> assets;
QString failure;
};
struct Baked {
std::map<QUuid, ImportedImage> images;
std::optional<QString> failure;
+1
View File
@@ -13,6 +13,7 @@ EditorSession::EditorSession(QObject *parent) : QObject(parent), m_busyTimer(thi
notify();
});
connect(&m_decoder, &QFutureWatcher<Decoded>::finished, this, &EditorSession::finishDecode);
connect(&m_photoshopReader, &QFutureWatcher<PhotoshopRead>::finished, this, &EditorSession::finishPhotoshopRead);
connect(&m_baker, &QFutureWatcher<Baked>::finished, this, &EditorSession::finishBake);
connect(&m_inverter, &QFutureWatcher<Inverted>::finished, this, &EditorSession::finishInvert);
connect(&m_wand, &QFutureWatcher<Wanded>::finished, this, &EditorSession::finishWand);
+17
View File
@@ -16,6 +16,8 @@
#include "Document/EditorSession+Jobs.h"
#include "Document/EditorSession+Model.h"
#include "IO/ProjectStore.h"
#include "IO/PSD/PSDDocumentBuilder.h"
#include "UI/PSDConversionSheet.h"
#include "Rendering/CanvasViewport.h"
#include "Rendering/EffectsPreviewCache.h"
#include <QFutureWatcher>
@@ -66,6 +68,8 @@ public:
void setIsImporting(bool importing);
std::optional<QString> importError() const { return m_importError; }
void setImportError(std::optional<QString> error);
bool showsConversionSheet() const { return m_showsConversionSheet; }
const std::optional<PSDConversionRequest> &conversionRequest() const { return m_conversionRequest; }
std::optional<QUuid> renamingLayerID() const { return m_renamingLayerID; }
void setRenamingLayerID(std::optional<QUuid> id);
@@ -233,6 +237,12 @@ public:
// Files decode off the UI thread; `done` follows this request.
void importImages(const QList<QUrl> &urls, std::optional<QPointF> point = std::nullopt, std::function<void()> done = {});
void insert(const ImportedImage &asset, std::optional<QPointF> centeredAt = std::nullopt);
// The sheet shows while a Photoshop file is read.
void beginPSDReading(const QString &title, const QString &confirmTitle);
void finishPSDReading(const std::vector<PSDConversion> &conversions, std::function<void(bool)> answer);
void endPSDReading();
void finishConversion(bool confirmed);
void insertPhotoshop(const PSDImport &imported, const QString &named, std::optional<QPointF> centeredAt = std::nullopt);
void createDocument(int width, int height, bool emptyLayer = false);
void fit();
void zoom(double value, std::optional<QPointF> anchor = std::nullopt);
@@ -312,6 +322,7 @@ private:
void drainImports();
void decodeNext();
void finishDecode();
void finishPhotoshopRead();
void endDuplicateTransform();
// Clone Stamp is family 1, the Smear 2, others 0.
static int tipFamily(NavigationTool tool);
@@ -371,6 +382,12 @@ private:
std::optional<QPointF> m_importPoint;
QStringList m_importFailures;
QFutureWatcher<Decoded> m_decoder;
QFutureWatcher<PhotoshopRead> m_photoshopReader;
bool m_showsConversionSheet = false;
std::optional<PSDConversionRequest> m_conversionRequest;
std::function<void(bool)> m_conversionAnswer;
// Cancel pressed while the file was still read.
bool m_conversionCancelled = false;
std::optional<QString> m_brushError;
// The layers a bake under way will delete.
std::vector<QUuid> m_bakingIDs;
+12 -5
View File
@@ -7,11 +7,12 @@
#include <algorithm>
namespace {
// Swift's order: PNG, JPEG, HEIC, TIFF, then any picture.
// Swift's order: PNG, JPEG, HEIC, TIFF, Photoshop, 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")})
for (const QString &format : {QStringLiteral("image/png"), QStringLiteral("image/jpeg"), QStringLiteral("image/heic"), QStringLiteral("image/tiff"),
QStringLiteral("image/vnd.adobe.photoshop")})
if (data.hasFormat(format))
formats << format;
for (const QString &format : data.formats())
@@ -27,14 +28,20 @@ std::optional<QUrl> temporaryFile(const QMimeData &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));
// Its own folder keeps the name the layers show.
const QString folder = QDir::temp().filePath(QUuid::createUuid().toString(QUuid::WithoutBraces).toUpper());
if (!QDir().mkpath(folder)) {
qCWarning(lcIO).noquote() << "could not make a folder for a dropped picture:" << folder;
return std::nullopt;
}
QFile file(QDir(folder).filePath(QStringLiteral("Dropped.") + (suffix.isEmpty() ? QStringLiteral("png") : suffix)));
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());
QDir().rmdir(folder);
return std::nullopt;
}
return QUrl::fromLocalFile(file.fileName());
@@ -91,7 +98,7 @@ void ImageFileDrop::importProviders(const QMimeData &data, EditorSession &sessio
}
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.");
const QString message = QStringLiteral("Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) files from the file manager.");
session.setImportError(session.importError() ? *session.importError() + QStringLiteral("\n\n") + message : message);
}
if (done)
+13 -1
View File
@@ -1,4 +1,6 @@
#include "IO/ImageImporter.h"
#include "IO/PSD/PSDDocumentBuilder.h"
#include "IO/PSD/PSDReader.h"
#include "Logging.h"
#include "Rendering/RasterSnapshot.h"
#include <QColorSpace>
@@ -16,7 +18,7 @@ QString description(ImageImportError::Kind 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.");
return QStringLiteral("Choose a JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) file.");
case ImageImportError::Kind::tooLarge:
return QStringLiteral("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit.");
}
@@ -241,3 +243,13 @@ ImportedImage ImageImporter::decode(const QString &path, qint64 remainingPixels)
qCInfo(lcIO).noquote() << "imported" << path << image.width() << "x" << image.height();
return ImportedImage(image, thumbnail, name.isEmpty() ? QFileInfo(path).fileName() : name);
}
PSDDocument ImageImporter::loadPhotoshop(const QString &path, qint64 remainingPixels)
{
return PSDReader::read(path, remainingPixels);
}
std::map<QUuid, ImportedImage> ImageImporter::photoshopAssets(const PSDDocument &document)
{
return PSDDocumentBuilder::assets(document);
}
+5
View File
@@ -1,11 +1,14 @@
#pragma once
#include <QImage>
#include <QString>
#include <QUuid>
#include <compare>
#include <map>
#include <memory>
#include <stdexcept>
class RasterSnapshot;
struct PSDDocument;
// Which pixels an asset holds, as CGImage identity did.
struct ImageIdentity {
@@ -45,4 +48,6 @@ namespace ImageImporter {
void liftAllocationLimit();
// JPEG, PNG, TIFF or HEIC: upright, sRGB, premultiplied.
ImportedImage decode(const QString &path, qint64 remainingPixels = 100'000'000);
PSDDocument loadPhotoshop(const QString &path, qint64 remainingPixels = 100'000'000);
std::map<QUuid, ImportedImage> photoshopAssets(const PSDDocument &document);
}
+94
View File
@@ -0,0 +1,94 @@
#include "IO/PSD/PSDChannelCoder.h"
#include "Document/BrushStroke.h"
#include "IO/PSD/PSDTypes.h"
#include <QColorSpace>
namespace {
std::vector<uchar> unpackRLE(int width, int height, const QByteArray &data)
{
qsizetype offset = 0;
const auto next = [&]() -> uchar {
if (offset >= data.size())
throw PSDError(PSDError::Kind::truncated);
return uchar(data[offset++]);
};
std::vector<qsizetype> counts(static_cast<size_t>(height));
for (int row = 0; row < height; ++row) {
const uchar hi = next(), lo = next();
counts[size_t(row)] = qsizetype(hi) << 8 | lo;
}
std::vector<uchar> plane(size_t(width) * size_t(height));
for (int row = 0; row < height; ++row) {
const qsizetype end = offset + counts[size_t(row)];
if (end > data.size())
throw PSDError(PSDError::Kind::truncated);
int written = 0;
uchar *line = plane.data() + size_t(row) * size_t(width);
while (written < width) {
if (offset >= end)
throw PSDError(PSDError::Kind::truncated);
const int n = qint8(data[offset++]);
if (n >= 0) {
const int count = n + 1;
if (written + count > width || offset + count > end)
throw PSDError(PSDError::Kind::truncated);
std::copy_n(data.constData() + offset, count, line + written);
offset += count;
written += count;
} else if (n != -128) {
const int count = 1 - n;
if (written + count > width || offset >= end)
throw PSDError(PSDError::Kind::truncated);
std::fill_n(line + written, count, uchar(data[offset++]));
written += count;
}
}
offset = end;
}
return plane;
}
}
std::vector<uchar> PSDChannelCoder::decode(int compression, int width, int height, const QByteArray &data)
{
if (width <= 0 || height <= 0)
return {};
const qsizetype expected = qsizetype(width) * height;
switch (compression) {
case 0:
if (data.size() < expected)
throw PSDError(PSDError::Kind::truncated);
return std::vector<uchar>(data.constData(), data.constData() + expected);
case 1:
return unpackRLE(width, height, data);
default:
throw PSDError(PSDError::Kind::unsupportedCompression);
}
}
QImage PSDChannelCoder::rgbaImage(int width, int height, const std::vector<uchar> &red, const std::vector<uchar> &green,
const std::vector<uchar> &blue, const std::vector<uchar> &alpha)
{
QImage image = BrushRaster::context(width, height, false);
image.setColorSpace(QColorSpace::SRgb);
for (int y = 0; y < height; ++y) {
uchar *row = image.scanLine(y);
for (int x = 0; x < width; ++x) {
const size_t i = size_t(y) * size_t(width) + size_t(x);
const unsigned a = alpha[i];
row[x * 4] = uchar((red[i] * a + 127) / 255);
row[x * 4 + 1] = uchar((green[i] * a + 127) / 255);
row[x * 4 + 2] = uchar((blue[i] * a + 127) / 255);
row[x * 4 + 3] = uchar(a);
}
}
return image;
}
QImage PSDChannelCoder::maskImage(int width, int height, const std::vector<uchar> &gray)
{
QImage image = BrushRaster::context(width, height, true);
for (int y = 0; y < height; ++y)
std::copy_n(gray.data() + size_t(y) * size_t(width), width, image.scanLine(y));
return image;
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <QByteArray>
#include <QImage>
#include <vector>
// Photoshop's layer channels: raw and PackBits (Adobe's 2019 specification).
namespace PSDChannelCoder {
std::vector<uchar> decode(int compression, int width, int height, const QByteArray &data);
// Straight planes into premultiplied RGBA, sRGB.
QImage rgbaImage(int width, int height, const std::vector<uchar> &red, const std::vector<uchar> &green,
const std::vector<uchar> &blue, const std::vector<uchar> &alpha);
QImage maskImage(int width, int height, const std::vector<uchar> &gray);
}
+122
View File
@@ -0,0 +1,122 @@
#include "IO/PSD/PSDDocumentBuilder.h"
#include "Document/PixelAdjust.h"
#include "IO/ProjectStore.h"
namespace {
ImportedImage imported(const QImage &image, const QString &name)
{
return ImportedImage(image, PixelAdjust::thumbnail(image), name);
}
std::vector<QString> notes(const PSDRecord &record)
{
std::vector<QString> result;
if (record.kind == PSDLayerKind::text)
result.push_back(QStringLiteral("Editable Photoshop text becomes pixels and can’t be retyped."));
if (record.kind == PSDLayerKind::smartObject)
result.push_back(QStringLiteral("The smart object was rasterized. Linked contents can’t be edited."));
if (record.kind == PSDLayerKind::effects)
result.push_back(QStringLiteral("Layer effects were discarded, so the appearance may differ."));
if (record.kind == PSDLayerKind::vector) {
if (record.shape)
result.insert(result.end(), record.shapeNotes.begin(), record.shapeNotes.end());
else
result.push_back(QStringLiteral("Vector shape was rasterized to pixels."));
}
if (record.isGroup) {
if (record.blendKey != QLatin1String("pass") && record.blendKey != QLatin1String("norm"))
result.push_back(QStringLiteral("Folder blend mode “%1” isn’t supported. The folder will be pass-through.").arg(record.blendKey));
} else if (!record.blendMode() && record.blendKey != QLatin1String("pass")) {
// Swift trims `.whitespaces`: spaces and tabs.
QString key = record.blendKey;
while (!key.isEmpty() && (key.front() == QLatin1Char(' ') || key.front() == QLatin1Char('\t')))
key.remove(0, 1);
while (!key.isEmpty() && (key.back() == QLatin1Char(' ') || key.back() == QLatin1Char('\t')))
key.chop(1);
result.push_back(QStringLiteral("Blend mode “%1” isn’t supported and will be applied as Normal.").arg(key));
}
if (record.kind == PSDLayerKind::adjustment)
result.push_back(record.adjustment ? QStringLiteral("Adjustment parameters may not match Photoshop exactly.")
: QStringLiteral("This adjustment type isn’t supported and was skipped."));
return result;
}
ImageLayer layerFor(const PSDRecord &record, QSizeF canvas, const std::map<QUuid, ImportedImage> &assets)
{
ImageLayer layer(record.name, canvas);
if (record.image && !record.isGroup && !record.adjustment) {
const ImportedImage asset = assets.contains(record.id) ? assets.at(record.id) : imported(*record.image, record.name);
const QSizeF size = record.bounds.width() > 0 && record.bounds.height() > 0 ? record.bounds.size() : QSizeF(record.image->size());
layer = ImageLayer(asset, record.bounds.topLeft());
layer.transform.size = size;
if (record.shape)
layer.shape = LayerShape{*record.shape, asset.identity()};
}
layer.id = record.id;
layer.isVisible = record.isVisible;
layer.parentID = record.parentID;
layer.isGroup = record.isGroup;
layer.opacity = std::clamp(record.opacity, 0.0, 1.0);
if (!record.isGroup) {
layer.blendMode = record.blendMode().value_or(LayerBlendMode::normal);
layer.adjustment = record.adjustment;
}
return layer;
}
}
std::map<QUuid, ImportedImage> PSDDocumentBuilder::assets(const PSDDocument &document)
{
std::map<QUuid, ImportedImage> result;
for (const PSDRecord &record : document.layers)
if (record.image)
result.emplace(record.id, imported(*record.image, record.name));
return result;
}
PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std::map<QUuid, ImportedImage> &assets)
{
std::vector<PSDConversion> conversions;
std::vector<ImageLayer> layers;
const QSizeF canvas(document.width, document.height);
for (const PSDRecord &record : document.layers) {
for (const QString &note : notes(record))
conversions.push_back(PSDConversion{.layerName = record.name, .message = note});
if (record.kind == PSDLayerKind::adjustment && !record.adjustment)
continue;
ImageLayer layer = layerFor(record, canvas, assets);
if (record.mask) {
try {
layer.mask = LayerMask(LayerMask::assetFrom(*record.mask), record.maskEnabled, std::nullopt, record.maskLinked);
} catch (const std::runtime_error &) {
// Swift's `try?`: a mask that fails is reported, not fatal.
conversions.push_back(PSDConversion{.layerName = record.name,
.message = QStringLiteral("The layer mask couldn’t be converted to 8-bit grayscale and was skipped.")});
}
}
layers.push_back(std::move(layer));
}
// A clipped layer takes the nearest unclipped pixels below.
std::map<std::optional<QUuid>, QUuid> baseForParent;
for (const PSDRecord &record : document.layers) {
const int index = indexOf(layers, record.id);
if (index < 0)
continue;
ImageLayer &layer = layers[size_t(index)];
if (record.clipping) {
// A clipped folder breaks the live mask graph: never linked.
const int source = !layer.isGroup && baseForParent.contains(record.parentID) ? indexOf(layers, baseForParent.at(record.parentID)) : -1;
if (source >= 0) {
layer.maskSourceID = layers[size_t(source)].id;
} else {
conversions.push_back(PSDConversion{.layerName = record.name,
.message = QStringLiteral("This clipping mask’s base isn’t supported, so clipping was skipped.")});
}
} else if (!layer.isGroup && !layer.adjustment) {
baseForParent[record.parentID] = record.id;
} else {
baseForParent.erase(record.parentID);
}
}
return PSDImport{document.width, document.height, document.resolution, std::move(layers), std::move(conversions)};
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "IO/PSD/PSDTypes.h"
#include <map>
struct PSDImport {
int width;
int height;
double resolution;
std::vector<ImageLayer> layers;
std::vector<PSDConversion> conversions;
};
// A read Photoshop file as layers, with its conversions.
namespace PSDDocumentBuilder {
// Each pixel record's asset and thumbnail, off the UI thread.
std::map<QUuid, ImportedImage> assets(const PSDDocument &document);
PSDImport makeImport(const PSDDocument &document, const std::map<QUuid, ImportedImage> &assets = {});
}
+97
View File
@@ -0,0 +1,97 @@
#include "IO/PSD/PSDReader.h"
#include <QtEndian>
#include <algorithm>
namespace {
quint16 u16(const QByteArray &data, qsizetype at)
{
return qFromBigEndian<quint16>(data.constData() + at);
}
qint16 i16(const QByteArray &data, qsizetype at)
{
return qFromBigEndian<qint16>(data.constData() + at);
}
std::optional<LayerAdjustment> levels(const QByteArray &data)
{
if (data.size() < 292)
return std::nullopt;
LevelsSettings settings;
for (int channel = 0; channel < 4; ++channel) {
const qsizetype base = 2 + channel * 10;
settings.ranges[size_t(channel)] = LevelRange{double(u16(data, base)), double(u16(data, base + 8)) / 256, double(u16(data, base + 2)),
double(u16(data, base + 4)), double(u16(data, base + 6))}
.normalized();
}
return LayerAdjustment{.kind = AdjustmentKind::levels, .levels = settings};
}
std::optional<LayerAdjustment> curves(const QByteArray &data)
{
if (data.size() < 5)
return std::nullopt;
qsizetype at = data[0] == 0 ? 1 : 0;
const quint16 version = u16(data, at);
at += 2;
if (version != 1 && version != 4)
return std::nullopt;
// Five bytes at least: the version and count always fit.
const int count = u16(data, at);
at += 2;
CurvesSettings settings;
for (int channel = 0; channel < std::min(4, count); ++channel) {
if (at + 2 > data.size())
return std::nullopt;
const int points = u16(data, at);
at += 2;
std::vector<CurvePoint> curve;
for (int point = 0; point < points; ++point) {
if (at + 4 > data.size())
return std::nullopt;
const double output = u16(data, at), input = u16(data, at + 2);
at += 4;
curve.push_back(CurvePoint{std::min(255.0, input), std::min(255.0, output)});
}
if (curve.size() >= 2) {
std::stable_sort(curve.begin(), curve.end(), [](const CurvePoint &lhs, const CurvePoint &rhs) { return lhs.x < rhs.x; });
if (curve.front().x != 0)
curve.insert(curve.begin(), CurvePoint{0, curve.front().y});
if (curve.back().x != 255)
curve.push_back(CurvePoint{255, curve.back().y});
settings.channels[size_t(channel)] = curve;
}
}
if (!settings.isValid())
return std::nullopt;
return LayerAdjustment{.kind = AdjustmentKind::curves, .curves = settings};
}
std::optional<LayerAdjustment> hue(const QByteArray &data)
{
if (data.size() < 4)
return std::nullopt;
HueSaturationSettings settings(0, 0, 0, data[2] != 0);
qsizetype at = 4;
for (const ColorRange range : allColorRanges) {
if (at + 6 > data.size())
break;
settings.adjustments[range] = RangeAdjustment{double(i16(data, at)), double(i16(data, at + 2)), double(i16(data, at + 4))};
at += 6;
}
return LayerAdjustment{.kind = AdjustmentKind::hsv, .hsvSettings = settings};
}
}
std::optional<LayerAdjustment> PSDAdjustments::parse(const std::map<QString, QByteArray> &extra)
{
if (extra.contains(QStringLiteral("levl")))
return levels(extra.at(QStringLiteral("levl")));
if (extra.contains(QStringLiteral("curv")))
return curves(extra.at(QStringLiteral("curv")));
if (extra.contains(QStringLiteral("hue2")))
return hue(extra.at(QStringLiteral("hue2")));
if (extra.contains(QStringLiteral("hue ")))
return hue(extra.at(QStringLiteral("hue ")));
return std::nullopt;
}
+438
View File
@@ -0,0 +1,438 @@
#include "IO/PSD/PSDReader.h"
#include "IO/PSD/PSDChannelCoder.h"
#include "IO/PSD/PSDVector.h"
#include "Logging.h"
#include <QFile>
namespace {
// Bytes 0x80–0xFF in Mac OS Roman, as Python's `mac_roman`.
constexpr char16_t macRoman[128] = {
0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3,
0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB, 0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3,
0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3,
0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8,
0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, 0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA,
0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB,
0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D,
0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178, 0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02,
0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE,
0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC,
0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, 0x02DB, 0x02C7,
};
QString fromMacRoman(const QByteArray &bytes)
{
QString text;
text.reserve(bytes.size());
for (const char byte : bytes)
text += uchar(byte) < 0x80 ? QChar(uchar(byte)) : QChar(macRoman[uchar(byte) - 0x80]);
return text;
}
// Swift's `.ascii` decoding: any byte past 127 reads as nothing.
QString ascii(const QByteArray &bytes)
{
for (const char byte : bytes)
if (uchar(byte) > 127)
return QString();
return QString::fromLatin1(bytes);
}
struct Cursor {
const QByteArray &data;
qint64 offset = 0;
void need(qint64 count) const
{
if (offset < 0 || offset > data.size() || count > data.size() - offset)
throw PSDError(PSDError::Kind::truncated);
}
void skip(qint64 count)
{
if (count < 0)
throw PSDError(PSDError::Kind::truncated);
need(count);
offset += count;
}
uchar u8()
{
need(1);
return uchar(data[offset++]);
}
quint16 u16()
{
const quint16 high = u8();
return quint16(high << 8 | u8());
}
qint16 i16() { return qint16(u16()); }
quint32 u32()
{
const quint32 high = u16();
return high << 16 | u16();
}
qint32 i32() { return qint32(u32()); }
QByteArray bytes(qint64 count)
{
need(count);
offset += count;
return data.mid(offset - count, count);
}
QString string(qint64 count) { return ascii(bytes(count)); }
};
quint32 u32(const QByteArray &data, qsizetype at)
{
return quint32(uchar(data[at])) << 24 | quint32(uchar(data[at + 1])) << 16 | quint32(uchar(data[at + 2])) << 8 | uchar(data[at + 3]);
}
struct Channel {
int id;
qint64 length;
};
struct RawLayer {
QString name;
qint64 top = 0, left = 0, bottom = 0, right = 0;
uchar opacity = 255;
uchar fill = 255;
bool clipping = false;
bool hidden = false;
QString blendKey = QStringLiteral("norm");
std::vector<Channel> channels;
std::map<QString, QByteArray> extra;
qint64 maskTop = 0, maskLeft = 0, maskBottom = 0, maskRight = 0;
bool maskDisabled = false;
bool maskLinked = true;
bool maskFromRender = false;
bool hasMask = false;
std::optional<qint64> section;
std::optional<QImage> image;
std::optional<QImage> maskImage;
bool has(std::initializer_list<const char *> keys) const
{
return std::any_of(keys.begin(), keys.end(), [this](const char *key) { return extra.contains(QString::fromLatin1(key)); });
}
};
std::optional<QString> unicodeName(const QByteArray &data)
{
if (data.size() < 4)
return std::nullopt;
const qint64 count = u32(data, 0);
if (count <= 0 || data.size() < 4 + count * 2)
return std::nullopt;
QString name(qsizetype(count), Qt::Uninitialized);
for (qint64 i = 0; i < count; ++i)
name[qsizetype(i)] = QChar(quint16(uchar(data[4 + i * 2]) << 8 | uchar(data[5 + i * 2])));
// Swift trims NUL from both ends.
qsizetype from = 0, to = name.size();
while (from < to && name[from] == QChar(0))
++from;
while (to > from && name[to - 1] == QChar(0))
--to;
return name.mid(from, to - from);
}
RawLayer readRecord(Cursor &cursor)
{
RawLayer layer;
layer.top = cursor.i32();
layer.left = cursor.i32();
layer.bottom = cursor.i32();
layer.right = cursor.i32();
const int channelCount = cursor.u16();
if (channelCount > 56)
throw ImageImportError(ImageImportError::Kind::tooLarge);
for (int index = 0; index < channelCount; ++index) {
const int id = cursor.i16();
layer.channels.push_back({id, cursor.u32()});
}
if (cursor.string(4) != QLatin1String("8BIM"))
throw PSDError(PSDError::Kind::truncated);
layer.blendKey = cursor.string(4);
layer.opacity = cursor.u8();
layer.clipping = cursor.u8() != 0;
layer.hidden = (cursor.u8() & 2) != 0;
cursor.skip(1);
const qint64 extraLength = cursor.u32();
const qint64 extraEnd = cursor.offset + extraLength;
const qint64 maskLength = cursor.u32();
const qint64 maskEnd = cursor.offset + maskLength;
if (maskLength >= 20) {
layer.hasMask = true;
layer.maskTop = cursor.i32();
layer.maskLeft = cursor.i32();
layer.maskBottom = cursor.i32();
layer.maskRight = cursor.i32();
// The mask's default colour is skipped, as Swift ignores it.
cursor.skip(1);
const uchar flags = cursor.u8();
layer.maskDisabled = (flags & 2) != 0;
layer.maskLinked = (flags & 1) == 0;
layer.maskFromRender = (flags & 8) != 0;
}
cursor.offset = maskEnd;
cursor.skip(cursor.u32());
const int nameCount = cursor.u8();
layer.name = fromMacRoman(cursor.bytes(nameCount));
cursor.skip((4 - ((nameCount + 1) % 4)) % 4);
while (cursor.offset + 12 <= extraEnd) {
const QString signature = cursor.string(4);
if (signature != QLatin1String("8BIM") && signature != QLatin1String("8B64"))
break;
const QString key = cursor.string(4);
qint64 length;
if (signature == QLatin1String("8B64")) {
if (cursor.offset + 8 > extraEnd)
break;
const quint64 raw = quint64(cursor.u32()) << 32 | cursor.u32();
if (raw > quint64(std::numeric_limits<qint64>::max()))
throw ImageImportError(ImageImportError::Kind::tooLarge);
length = qint64(raw);
} else {
length = cursor.u32();
}
const QByteArray payload = cursor.bytes(length);
if (length % 2 == 1)
cursor.skip(1);
layer.extra[key] = payload;
if (key == QLatin1String("luni"))
if (const std::optional<QString> unicode = unicodeName(payload))
layer.name = *unicode;
if (key == QLatin1String("iOpa") && !payload.isEmpty())
layer.fill = uchar(payload[0]);
if ((key == QLatin1String("lsct") || key == QLatin1String("lsdk")) && payload.size() >= 4)
layer.section = u32(payload, 0);
}
cursor.offset = extraEnd;
return layer;
}
}
namespace {
// Transparency, red, green, blue and the user mask only.
bool unpacked(int id)
{
return id == -1 || id == 0 || id == 1 || id == 2 || id == -2;
}
void decodeChannels(Cursor &cursor, RawLayer &layer, qint64 remainingPixels)
{
std::map<int, std::vector<uchar>> planes;
const qint64 width = std::max<qint64>(0, layer.right - layer.left), height = std::max<qint64>(0, layer.bottom - layer.top);
const qint64 maskWidth = std::max<qint64>(0, layer.maskRight - layer.maskLeft), maskHeight = std::max<qint64>(0, layer.maskBottom - layer.maskTop);
const qint64 budget = std::max<qint64>(0, remainingPixels);
if (width > 0 && height > 0 && (width > 30'000 || height > 30'000 || width * height > budget))
throw ImageImportError(ImageImportError::Kind::tooLarge);
if (layer.hasMask && maskWidth > 0 && maskHeight > 0 && (maskWidth > 30'000 || maskHeight > 30'000 || maskWidth * maskHeight > budget))
throw ImageImportError(ImageImportError::Kind::tooLarge);
for (const Channel &channel : layer.channels) {
const qint64 start = cursor.offset;
if (unpacked(channel.id) && channel.length >= 2) {
const int compression = cursor.u16();
const QByteArray payload = cursor.bytes(channel.length - 2);
const bool isMask = channel.id == -2;
const qint64 w = isMask ? maskWidth : width, h = isMask ? maskHeight : height;
if (w > 0 && h > 0)
planes[channel.id] = PSDChannelCoder::decode(compression, int(w), int(h), payload);
}
cursor.offset = start + std::max<qint64>(0, channel.length);
}
// Each plane decodes to exactly its size: no count check.
if (layer.hasMask && maskWidth > 0 && maskHeight > 0 && planes.contains(-2))
layer.maskImage = PSDChannelCoder::maskImage(int(maskWidth), int(maskHeight), planes.at(-2));
if (width <= 0 || height <= 0)
return;
const size_t count = size_t(width * height);
const auto plane = [&](int id, uchar fill) { return planes.contains(id) ? planes.at(id) : std::vector<uchar>(count, fill); };
layer.image = PSDChannelCoder::rgbaImage(int(width), int(height), plane(0, 0), plane(1, 0), plane(2, 0), plane(-1, 255));
}
PSDLayerKind kindOf(const RawLayer &layer, bool isGroup)
{
if (isGroup)
return PSDLayerKind::group;
if (layer.has({"TySh", "tySh", "txt2"}))
return PSDLayerKind::text;
if (layer.has({"vmsk", "vsms", "vogk"}))
return PSDLayerKind::vector;
if (layer.has({"SoLd", "SoLE"}))
return PSDLayerKind::smartObject;
if (layer.has({"lfx2", "lrFX", "lmfx"}))
return PSDLayerKind::effects;
for (const QString &key : PSDReader::adjustmentKeys)
if (layer.extra.contains(key))
return PSDLayerKind::adjustment;
return PSDLayerKind::raster;
}
std::vector<PSDRecord> assemble(const std::vector<RawLayer> &raw, QSizeF canvas, qint64 remainingPixels)
{
std::vector<PSDRecord> result;
std::vector<QUuid> groups;
qint64 remaining = std::max<qint64>(0, remainingPixels);
for (const RawLayer &layer : raw) {
// Groups run bottom to top: divider, children, then the folder.
if (layer.section == 3) {
groups.push_back(QUuid::createUuid());
continue;
}
const bool isGroup = layer.section == 1 || layer.section == 2;
QUuid id = QUuid::createUuid();
if (isGroup && !groups.empty()) {
id = groups.back();
groups.pop_back();
}
PSDRecord record;
record.id = id;
record.name = layer.name.isEmpty() ? QStringLiteral("Layer") : layer.name;
if (!groups.empty())
record.parentID = groups.back();
record.isGroup = isGroup;
record.isVisible = !layer.hidden;
const bool passes = layer.blendKey == QLatin1String("pass") || layer.blendKey == QLatin1String("norm");
record.blendKey = isGroup && passes ? QStringLiteral("pass") : layer.blendKey;
record.clipping = layer.clipping;
record.kind = kindOf(layer, isGroup);
const bool hasEffects = record.kind == PSDLayerKind::effects || layer.has({"lfx2", "lrFX", "lmfx"});
record.opacity = hasEffects && layer.fill != 255 ? layer.opacity / 255.0 : (layer.opacity / 255.0) * (layer.fill / 255.0);
record.bounds = isGroup ? QRectF(QPointF(0, 0), canvas) : QRectF(layer.left, layer.top, std::max<qint64>(0, layer.right - layer.left), std::max<qint64>(0, layer.bottom - layer.top));
record.image = isGroup ? std::nullopt : layer.image;
std::optional<PSDVector::Live> live = isGroup ? std::nullopt : PSDVector::live(layer.extra, canvas, remaining);
if (live) {
record.image = live->image;
record.bounds = live->bounds;
record.shape = live->style;
record.shapeNotes = live->notes;
record.kind = PSDLayerKind::vector;
remaining = std::max<qint64>(0, remaining - qint64(live->image.width()) * live->image.height());
} else if (!record.image && !isGroup) {
if (const std::optional<PSDVector::Raster> raster = PSDVector::raster(layer.extra, canvas, remaining)) {
record.image = raster->image;
record.bounds = raster->bounds;
record.kind = PSDLayerKind::vector;
remaining = std::max<qint64>(0, remaining - qint64(raster->image.width()) * raster->image.height());
}
}
record.mask = layer.maskFromRender ? std::nullopt : layer.maskImage;
record.maskEnabled = !layer.maskDisabled;
record.maskLinked = layer.maskLinked;
if (!isGroup)
record.adjustment = PSDAdjustments::parse(layer.extra);
if (record.adjustment)
record.kind = PSDLayerKind::adjustment;
result.push_back(std::move(record));
}
if (!groups.empty())
throw PSDError(PSDError::Kind::truncated);
return result;
}
PSDDocument parse(const QByteArray &data, qint64 remainingPixels)
{
Cursor cursor{data};
if (cursor.string(4) != QLatin1String("8BPS"))
throw ImageImportError(ImageImportError::Kind::unreadable);
if (cursor.u16() != 1)
throw PSDError(PSDError::Kind::unsupportedVersion);
cursor.skip(6);
cursor.u16();
const qint64 canvasHeight = cursor.u32(), canvasWidth = cursor.u32();
const int depth = cursor.u16(), mode = cursor.u16();
if (canvasWidth < 1 || canvasWidth > 30'000 || canvasHeight < 1 || canvasHeight > 30'000 || canvasWidth * canvasHeight > 100'000'000)
throw ImageImportError(ImageImportError::Kind::tooLarge);
if (depth != 8)
throw PSDError(PSDError::Kind::unsupportedDepth);
if (mode != 3)
throw PSDError(PSDError::Kind::unsupportedColorMode);
cursor.skip(cursor.u32());
const qint64 resourcesLength = cursor.u32();
const qint64 resourcesEnd = cursor.offset + resourcesLength;
double resolution = 72;
while (cursor.offset + 12 <= resourcesEnd) {
if (cursor.string(4) != QLatin1String("8BIM"))
break;
const int id = cursor.u16();
const int nameLength = cursor.u8();
cursor.skip(nameLength);
if ((nameLength + 1) % 2 == 1)
cursor.skip(1);
const qint64 length = cursor.u32();
const qint64 dataStart = cursor.offset;
if (id == 1005 && length >= 4) {
resolution = cursor.u32() / 65536.0;
// A fixed-point number is finite; below one reads as 72.
resolution = std::min(9600.0, resolution < 1 ? 72 : resolution);
}
cursor.offset = dataStart + length;
if (length % 2 == 1)
cursor.skip(1);
}
cursor.offset = resourcesEnd;
const qint64 layerSection = cursor.u32();
const PSDDocument empty{int(canvasWidth), int(canvasHeight), resolution, {}};
if (layerSection < 4)
return empty;
// The layer info length is skipped, as in Swift.
cursor.u32();
const qint64 count = std::abs(qint64(cursor.i16()));
if (count > 10'000)
throw ImageImportError(ImageImportError::Kind::tooLarge);
std::vector<RawLayer> raw;
raw.reserve(size_t(count));
for (qint64 index = 0; index < count; ++index)
raw.push_back(readRecord(cursor));
qint64 usedPixels = 0;
for (RawLayer &layer : raw) {
decodeChannels(cursor, layer, remainingPixels - usedPixels);
if (layer.image)
usedPixels += qint64(layer.image->width()) * layer.image->height();
}
return PSDDocument{int(canvasWidth), int(canvasHeight), resolution, assemble(raw, QSizeF(canvasWidth, canvasHeight), remainingPixels - usedPixels)};
}
}
const std::set<QString> PSDReader::adjustmentKeys{
QStringLiteral("levl"), QStringLiteral("curv"), QStringLiteral("hue2"), QStringLiteral("hue "), QStringLiteral("expA"), QStringLiteral("grdm"),
QStringLiteral("brit"), QStringLiteral("blnc"), QStringLiteral("nvrt"), QStringLiteral("thrs"), QStringLiteral("post"), QStringLiteral("mixr"),
QStringLiteral("selc"), QStringLiteral("blwh"), QStringLiteral("phfl"), QStringLiteral("vibA"),
};
bool PSDReader::matches(const QString &path)
{
QFile file(path);
return file.open(QIODevice::ReadOnly) && file.read(4) == "8BPS";
}
bool PSDReader::matches(const QByteArray &data)
{
return data.startsWith("8BPS");
}
PSDDocument PSDReader::read(const QString &path, qint64 remainingPixels)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
qCWarning(lcIO).noquote() << "cannot open the Photoshop file" << path << file.errorString();
throw ImageImportError(ImageImportError::Kind::unreadable);
}
// Swift's `mappedIfSafe`: mapped where it can be, else read.
if (uchar *mapped = file.map(0, file.size()))
return read(QByteArray::fromRawData(reinterpret_cast<const char *>(mapped), file.size()), remainingPixels);
const QByteArray bytes = file.readAll();
if (file.error() != QFileDevice::NoError) {
qCWarning(lcIO).noquote() << "cannot read the Photoshop file" << path << file.errorString();
throw ImageImportError(ImageImportError::Kind::unreadable);
}
return read(bytes, remainingPixels);
}
PSDDocument PSDReader::read(const QByteArray &data, qint64 remainingPixels)
{
try {
return parse(data, remainingPixels);
} catch (const std::runtime_error &error) {
qCWarning(lcIO) << "a Photoshop file was refused:" << error.what();
throw;
}
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "IO/PSD/PSDTypes.h"
#include <QByteArray>
#include <QString>
#include <map>
#include <set>
// Photoshop `.psd` from Adobe's 2019 specification; no GPL code.
namespace PSDReader {
bool matches(const QString &path);
bool matches(const QByteArray &data);
PSDDocument read(const QString &path, qint64 remainingPixels = 100'000'000);
PSDDocument read(const QByteArray &data, qint64 remainingPixels = 100'000'000);
extern const std::set<QString> adjustmentKeys;
}
// Levels, Curves and Hue/Saturation layers, as Swift reads them.
namespace PSDAdjustments {
std::optional<LayerAdjustment> parse(const std::map<QString, QByteArray> &extra);
}
+43
View File
@@ -0,0 +1,43 @@
#include "IO/PSD/PSDTypes.h"
#include <map>
namespace {
QString description(PSDError::Kind kind)
{
switch (kind) {
case PSDError::Kind::truncated:
return QStringLiteral("The Photoshop file could not be read. It may be damaged or incomplete.");
case PSDError::Kind::unsupportedVersion:
return QStringLiteral("Large Document (.psb) Photoshop files aren’t supported.");
case PSDError::Kind::unsupportedColorMode:
case PSDError::Kind::unsupportedDepth:
return QStringLiteral("Only 8-bit RGB Photoshop files can be imported.");
case PSDError::Kind::unsupportedCompression:
return QStringLiteral("This Photoshop file uses a layer compression method that isn’t supported.");
}
Q_UNREACHABLE();
}
}
PSDError::PSDError(Kind kind) : std::runtime_error(description(kind).toStdString()), kind(kind) {}
std::optional<LayerBlendMode> fromPSD(const QString &key)
{
static const std::map<QString, LayerBlendMode> modes{
{QStringLiteral("norm"), LayerBlendMode::normal}, {QStringLiteral("mul "), LayerBlendMode::multiply},
{QStringLiteral("scrn"), LayerBlendMode::screen}, {QStringLiteral("over"), LayerBlendMode::overlay},
{QStringLiteral("sLit"), LayerBlendMode::softLight}, {QStringLiteral("dark"), LayerBlendMode::darken},
{QStringLiteral("lite"), LayerBlendMode::lighten}, {QStringLiteral("diff"), LayerBlendMode::difference},
{QStringLiteral("div "), LayerBlendMode::colorDodge}, {QStringLiteral("idiv"), LayerBlendMode::colorBurn},
{QStringLiteral("hue "), LayerBlendMode::hue}, {QStringLiteral("sat "), LayerBlendMode::saturation},
{QStringLiteral("colr"), LayerBlendMode::color}, {QStringLiteral("lum "), LayerBlendMode::luminosity},
};
if (!modes.contains(key))
return std::nullopt;
return modes.at(key);
}
std::optional<LayerBlendMode> PSDRecord::blendMode() const
{
return fromPSD(blendKey);
}
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include "Document/EditorSession+Model.h"
#include <QImage>
#include <QRectF>
#include <QString>
#include <QUuid>
#include <optional>
#include <stdexcept>
#include <vector>
class PSDError : public std::runtime_error {
public:
enum class Kind { truncated, unsupportedVersion, unsupportedColorMode, unsupportedDepth, unsupportedCompression };
explicit PSDError(Kind kind);
Kind kind;
};
// A note on what the import changes, by layer.
struct PSDConversion {
QUuid id = QUuid::createUuid();
QString layerName;
QString message;
friend bool operator==(const PSDConversion &, const PSDConversion &) = default;
};
enum class PSDLayerKind { raster, group, adjustment, text, smartObject, effects, vector };
struct PSDRecord {
QUuid id;
std::optional<QUuid> parentID;
QString name;
bool isGroup = false;
bool isVisible = true;
double opacity = 1;
QString blendKey = QStringLiteral("norm");
bool clipping = false;
QRectF bounds;
std::optional<QImage> image;
std::optional<QImage> mask;
bool maskEnabled = true;
bool maskLinked = true;
std::optional<LayerAdjustment> adjustment;
PSDLayerKind kind = PSDLayerKind::raster;
std::optional<LayerShapeStyle> shape;
std::vector<QString> shapeNotes;
std::optional<LayerBlendMode> blendMode() const;
};
struct PSDDocument {
int width;
int height;
double resolution;
// Bottom to top, folders included; hidden dividers are not stored.
std::vector<PSDRecord> layers;
};
// Swift's `LayerBlendMode.fromPSD`: Photoshop's four-letter keys.
std::optional<LayerBlendMode> fromPSD(const QString &key);
+311
View File
@@ -0,0 +1,311 @@
#include "IO/PSD/PSDVector.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include <QPainter>
#include <QtEndian>
#include <bit>
#include <cmath>
namespace {
std::optional<QByteArray> entry(const PSDVector::Extra &extra, const char *key)
{
const QString name = QString::fromLatin1(key);
if (!extra.contains(name))
return std::nullopt;
return extra.at(name);
}
qint32 i32(const QByteArray &bytes, qsizetype at)
{
return qFromBigEndian<qint32>(bytes.constData() + at);
}
qint16 i16(const QByteArray &bytes, qsizetype at)
{
return qFromBigEndian<qint16>(bytes.constData() + at);
}
std::optional<qsizetype> offset(const QByteArray &key, const QByteArray &data, qsizetype start = 0)
{
const qsizetype found = start < data.size() ? data.indexOf(key, start) : -1;
if (found < 0)
return std::nullopt;
return found;
}
std::optional<double> doubleAt(qsizetype at, const QByteArray &data)
{
if (at + 8 > data.size())
return std::nullopt;
return std::bit_cast<double>(qFromBigEndian<quint64>(data.constData() + at));
}
std::optional<double> number(const QByteArray &data, const QByteArray &key)
{
const std::optional<qsizetype> start = offset(key, data);
if (!start)
return std::nullopt;
const qsizetype type = *start + key.size();
if (type + 12 > data.size() || data.mid(type, 4) != "doub")
return std::nullopt;
return doubleAt(type + 4, data);
}
std::optional<qint32> int32(const QByteArray &data, const QByteArray &key)
{
const std::optional<qsizetype> start = offset(key, data);
if (!start)
return std::nullopt;
const qsizetype type = *start + key.size();
if (type + 8 > data.size() || data.mid(type, 4) != "long")
return std::nullopt;
return i32(data, type + 4);
}
QPointF point(const QByteArray &bytes, qsizetype at, QSizeF canvas)
{
const double y = double(i32(bytes, at)) / 0x1000000;
const double x = double(i32(bytes, at + 4)) / 0x1000000;
return QPointF(x * canvas.width(), y * canvas.height());
}
// CGRect.integral, in doubles, so a wild box never overflows.
QRectF integral(const QRectF &box)
{
const double left = std::floor(box.left()), top = std::floor(box.top());
return QRectF(left, top, std::ceil(box.right()) - left, std::ceil(box.bottom()) - top);
}
// Within 30,000 a side and the pixel budget, else tooLarge.
QSize pixelSize(QSizeF size, qint64 remainingPixels)
{
if (std::abs(size.width()) > 30'000 || std::abs(size.height()) > 30'000)
throw ImageImportError(ImageImportError::Kind::tooLarge);
const qint64 budget = std::min(EditorSession::maxShapePixels, std::max<qint64>(0, remainingPixels));
// Boxes are whole here, so the whole pixels decide alone.
const int width = std::max(1, int(size.width())), height = std::max(1, int(size.height()));
if (qint64(width) * height > budget)
throw ImageImportError(ImageImportError::Kind::tooLarge);
return QSize(width, height);
}
struct Origination {
ShapeKind kind;
QRectF bounds;
double cornerRadius = 0;
};
// Photoshop's `vogk`: 1 and 2 rectangles, 5 an ellipse.
std::optional<Origination> origination(const std::optional<QByteArray> &data)
{
const std::optional<qint32> type = data ? int32(*data, "keyOriginType") : std::nullopt;
if (!type)
return std::nullopt;
ShapeKind kind;
switch (*type) {
case 1:
case 2:
kind = ShapeKind::rectangle;
break;
case 5:
kind = ShapeKind::ellipse;
break;
default:
return std::nullopt;
}
const qsizetype from = offset("keyOriginShapeBBox", *data).value_or(0);
const std::optional<double> left = PSDVector::unit(*data, "Left", from), top = PSDVector::unit(*data, "Top ", from),
right = PSDVector::unit(*data, "Rght", from), bottom = PSDVector::unit(*data, "Btom", from);
if (!left || !top || !right || !bottom)
return std::nullopt;
const QRectF bounds(*left, *top, *right - *left, *bottom - *top);
if (!(bounds.width() >= 1) || !(bounds.height() >= 1) || !std::isfinite(bounds.x()) || !std::isfinite(bounds.y())
|| !std::isfinite(bounds.width()) || !std::isfinite(bounds.height()))
return std::nullopt;
Origination origin{kind, bounds};
const std::optional<qsizetype> radiiAt = kind == ShapeKind::rectangle ? offset("keyOriginRRectRadii", *data) : std::nullopt;
if (radiiAt) {
std::vector<double> radii;
for (const char *key : {"topLeft", "topRight", "bottomRight", "bottomLeft"})
if (const std::optional<double> radius = PSDVector::unit(*data, key, *radiiAt))
radii.push_back(*radius);
if (radii.size() == 4) {
const auto [lo, hi] = std::minmax_element(radii.begin(), radii.end());
if (*hi - *lo > 0.5)
return std::nullopt;
origin.cornerRadius = *hi;
}
}
return origin;
}
// Four sharp anchors in one subpath read as a rectangle.
std::optional<Origination> sharpRect(const std::optional<QByteArray> &data, QSizeF canvas)
{
const std::optional<QPainterPath> outline = data ? PSDVector::path(*data, canvas) : std::nullopt;
if (!outline)
return std::nullopt;
qsizetype at = 8;
int remaining = 0;
std::vector<QPointF> anchors;
bool sharp = true;
while (at + 26 <= data->size()) {
const int type = i16(*data, at);
const QByteArray body = data->mid(at + 2, 24);
at += 26;
if (type == 0 || type == 3) {
if (!anchors.empty())
return std::nullopt;
remaining = i16(body, 0);
} else if ((type == 1 || type == 2 || type == 4 || type == 5) && remaining > 0) {
remaining -= 1;
const QPointF incoming = point(body, 0, canvas), anchor = point(body, 8, canvas), outgoing = point(body, 16, canvas);
if (std::hypot(incoming.x() - anchor.x(), incoming.y() - anchor.y()) > 0.5
|| std::hypot(outgoing.x() - anchor.x(), outgoing.y() - anchor.y()) > 0.5)
sharp = false;
anchors.push_back(anchor);
}
}
if (!sharp || anchors.size() != 4)
return std::nullopt;
const QRectF box = outline->boundingRect();
if (!(box.width() >= 1) || !(box.height() >= 1))
return std::nullopt;
return Origination{ShapeKind::rectangle, box};
}
}
std::optional<PSDVector::Live> PSDVector::live(const Extra &extra, QSizeF canvas, qint64 remainingPixels)
{
const std::optional<QByteArray> stroke = entry(extra, "vstk"), solid = entry(extra, "SoCo");
const bool fillEnabled = (stroke ? boolean(*stroke, "fillEnabled") : std::nullopt).value_or(solid.has_value());
const bool strokeEnabled = (stroke ? boolean(*stroke, "strokeEnabled") : std::nullopt).value_or(false);
const std::optional<RGB> fill = solid ? rgb(*solid) : std::nullopt;
if (!fillEnabled || !fill)
return std::nullopt;
std::optional<Origination> origin = origination(entry(extra, "vogk"));
if (!origin)
origin = sharpRect(extra.contains(QStringLiteral("vmsk")) ? entry(extra, "vmsk") : entry(extra, "vsms"), canvas);
if (!origin)
return std::nullopt;
QRectF box = integral(origin->bounds);
box.setSize(QSizeF(pixelSize(box.size(), remainingPixels)));
const LayerShapeStyle style{origin->kind, fill->r, fill->g, fill->b, origin->cornerRadius};
const QImage image = EditorSession::shapeImage(style.kind, box.size(), style.color(), style.cornerRadius);
std::vector<QString> notes;
if (strokeEnabled)
notes.push_back(QStringLiteral("The Photoshop stroke isn’t supported on shape layers and was omitted."));
return Live{style, box, image, notes};
}
std::optional<PSDVector::Raster> PSDVector::raster(const Extra &extra, QSizeF canvas, qint64 remainingPixels)
{
const std::optional<QByteArray> mask = extra.contains(QStringLiteral("vmsk")) ? entry(extra, "vmsk") : entry(extra, "vsms");
std::optional<QPainterPath> outline = mask ? path(*mask, canvas) : std::nullopt;
if (!outline)
return std::nullopt;
const std::optional<QByteArray> solid = entry(extra, "SoCo"), stroke = entry(extra, "vstk");
const std::optional<RGB> fill = solid ? rgb(*solid) : std::nullopt;
const bool fillEnabled = (stroke ? boolean(*stroke, "fillEnabled") : std::nullopt).value_or(fill.has_value());
const bool strokeEnabled = (stroke ? boolean(*stroke, "strokeEnabled") : std::nullopt).value_or(false);
const std::optional<RGB> strokeColor = stroke ? rgb(*stroke) : std::nullopt;
const double strokeWidth = (stroke ? unit(*stroke, "strokeStyleLineWidth") : std::nullopt).value_or(1);
if (!((fillEnabled && fill) || (strokeEnabled && strokeColor)))
return std::nullopt;
if (!std::isfinite(strokeWidth))
return std::nullopt;
if (strokeEnabled && !(strokeWidth >= 0 && strokeWidth <= 30'000))
throw ImageImportError(ImageImportError::Kind::tooLarge);
QRectF box = outline->boundingRect();
if (strokeEnabled) {
const double grow = std::ceil(strokeWidth / 2 + 1);
box.adjust(-grow, -grow, grow, grow);
}
box = integral(box);
const QSize size = pixelSize(box.size(), remainingPixels);
QImage image = BrushRaster::context(size.width(), size.height(), false);
QPainter painter(&image);
painter.translate(-box.x(), -box.y());
painter.setRenderHint(QPainter::Antialiasing);
// Both CoreGraphics fills here are nonzero winding.
outline->setFillRule(Qt::WindingFill);
if (fillEnabled && fill)
painter.fillPath(*outline, QColor::fromRgbF(float(fill->r), float(fill->g), float(fill->b)));
if (strokeEnabled && strokeColor) {
// SVG's miter rule is CoreGraphics': a bevel past the limit.
QPen pen(QColor::fromRgbF(float(strokeColor->r), float(strokeColor->g), float(strokeColor->b)), strokeWidth, Qt::SolidLine, Qt::FlatCap,
Qt::SvgMiterJoin);
pen.setMiterLimit(10);
painter.strokePath(*outline, pen);
}
painter.end();
return Raster{image, QRectF(box.topLeft(), QSizeF(size))};
}
std::optional<QPainterPath> PSDVector::path(const QByteArray &data, QSizeF canvas)
{
if (data.size() < 8 || !(canvas.width() > 0) || !(canvas.height() > 0))
return std::nullopt;
QPainterPath result;
qsizetype at = 8;
int remaining = 0;
bool closed = true, first = true;
QPointF previousOut;
while (at + 26 <= data.size()) {
const int type = i16(data, at);
const QByteArray body = data.mid(at + 2, 24);
at += 26;
if (type == 0 || type == 3) {
if (!first && closed)
result.closeSubpath();
remaining = i16(body, 0);
closed = type == 0;
first = true;
} else if ((type == 1 || type == 2 || type == 4 || type == 5) && remaining > 0) {
remaining -= 1;
const QPointF incoming = point(body, 0, canvas), anchor = point(body, 8, canvas), outgoing = point(body, 16, canvas);
if (first) {
result.moveTo(anchor);
first = false;
} else {
result.cubicTo(previousOut, incoming, anchor);
}
previousOut = outgoing;
}
}
if (!first && closed)
result.closeSubpath();
// A CGPath is empty only without elements.
if (result.elementCount() == 0)
return std::nullopt;
return result;
}
std::optional<PSDVector::RGB> PSDVector::rgb(const QByteArray &data)
{
const std::optional<double> r = number(data, "Rd "), g = number(data, "Grn "), b = number(data, "Bl ");
if (!r || !g || !b)
return std::nullopt;
const auto channel = [](double value) { return value > 1 ? std::min(255.0, std::max(0.0, value)) / 255 : std::min(1.0, std::max(0.0, value)); };
return RGB{channel(*r), channel(*g), channel(*b)};
}
std::optional<bool> PSDVector::boolean(const QByteArray &data, const QByteArray &key)
{
const std::optional<qsizetype> start = offset(key, data);
if (!start)
return std::nullopt;
const qsizetype type = *start + key.size();
if (type + 5 > data.size() || data.mid(type, 4) != "bool")
return std::nullopt;
return data[type + 4] != 0;
}
std::optional<double> PSDVector::unit(const QByteArray &data, const QByteArray &key, qsizetype from)
{
const std::optional<qsizetype> keyAt = offset(key, data, from);
const std::optional<qsizetype> units = keyAt ? offset("UntF", data, *keyAt) : std::nullopt;
if (!units)
return std::nullopt;
return doubleAt(*units + 8, data);
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include "Document/ShapeTool.h"
#include <QByteArray>
#include <QImage>
#include <QPainterPath>
#include <QRectF>
#include <map>
#include <optional>
// Photoshop vector masks rasterized, or fills mapped onto live shapes.
namespace PSDVector {
struct Raster {
QImage image;
QRectF bounds;
};
struct Live {
LayerShapeStyle style;
QRectF bounds;
QImage image;
std::vector<QString> notes;
};
using Extra = std::map<QString, QByteArray>;
std::optional<Live> live(const Extra &extra, QSizeF canvas, qint64 remainingPixels = 100'000'000);
std::optional<Raster> raster(const Extra &extra, QSizeF canvas, qint64 remainingPixels = 100'000'000);
std::optional<QPainterPath> path(const QByteArray &data, QSizeF canvas);
struct RGB {
double r;
double g;
double b;
};
std::optional<RGB> rgb(const QByteArray &data);
std::optional<bool> boolean(const QByteArray &data, const QByteArray &key);
std::optional<double> unit(const QByteArray &data, const QByteArray &key, qsizetype from = 0);
}
+89
View File
@@ -0,0 +1,89 @@
#include "UI/PSDConversionSheet.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QProgressBar>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
namespace {
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QWidget *parent, bool secondary = false)
{
auto *label = new QLabel(words, parent);
label->setTextFormat(Qt::PlainText);
label->setWordWrap(true);
QFont font = label->font();
font.setPixelSize(pixels);
font.setWeight(weight);
label->setFont(font);
if (secondary)
label->setForegroundRole(QPalette::PlaceholderText);
return label;
}
// Swift's `List`: each layer's name over its note.
QWidget *list(const std::vector<PSDConversion> &conversions, QWidget *parent)
{
auto *scroll = new QScrollArea(parent);
scroll->setObjectName(QStringLiteral("conversionList"));
scroll->setWidgetResizable(true);
auto *rows = new QWidget(scroll);
auto *column = new QVBoxLayout(rows);
for (const PSDConversion &item : conversions) {
auto *row = new QVBoxLayout;
row->setSpacing(4);
row->setContentsMargins(8, 4, 8, 4);
row->addWidget(text(item.layerName, 13, QFont::Bold, rows));
row->addWidget(text(item.message, 13, QFont::Normal, rows));
column->addLayout(row);
}
column->addStretch();
scroll->setWidget(rows);
scroll->setMinimumHeight(180);
return scroll;
}
}
PSDConversionSheet::PSDConversionSheet(const PSDConversionRequest &request, std::function<void(bool)> finish, QWidget *parent) : QWidget(parent)
{
setMinimumSize(520, 360);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(24, 24, 24, 24);
layout->setSpacing(16);
layout->addWidget(text(request.title, 17, QFont::Bold, this));
layout->addWidget(text(request.isReading ? QStringLiteral("Reading the file to see what needs converting.")
: QStringLiteral("OmaPhoto will convert these Photoshop features. Nothing is applied until you continue."),
13, QFont::Normal, this, true));
if (request.isReading) {
auto *reading = new QWidget(this);
reading->setMinimumHeight(180);
auto *row = new QHBoxLayout(reading);
row->setSpacing(10);
row->addStretch();
auto *spinner = new QProgressBar(reading);
spinner->setObjectName(QStringLiteral("conversionSpinner"));
spinner->setRange(0, 0);
spinner->setTextVisible(false);
spinner->setFixedWidth(16);
row->addWidget(spinner);
row->addWidget(text(QStringLiteral("Reading the Photoshop file…"), 13, QFont::Normal, reading, true));
row->addStretch();
layout->addWidget(reading, 1);
} else {
layout->addWidget(list(request.conversions, this), 1);
}
auto *buttons = new QHBoxLayout;
buttons->addStretch();
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
cancel->setObjectName(QStringLiteral("conversionCancel"));
cancel->setAutoDefault(false);
auto *confirm = new QPushButton(request.confirmTitle, this);
confirm->setObjectName(QStringLiteral("conversionConfirm"));
confirm->setDefault(true);
confirm->setEnabled(!request.isReading);
connect(cancel, &QPushButton::clicked, this, [finish] { finish(false); });
connect(confirm, &QPushButton::clicked, this, [finish] { finish(true); });
buttons->addWidget(cancel);
buttons->addWidget(confirm);
layout->addLayout(buttons);
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "IO/PSD/PSDTypes.h"
#include <QWidget>
#include <functional>
struct PSDConversionRequest {
QString title;
QString confirmTitle;
std::vector<PSDConversion> conversions;
// Still read: the sheet answers the click, reporting nothing.
bool isReading = false;
friend bool operator==(const PSDConversionRequest &, const PSDConversionRequest &) = default;
};
// What an import converts, confirmed before anything applies.
class PSDConversionSheet : public QWidget {
Q_OBJECT
public:
PSDConversionSheet(const PSDConversionRequest &request, std::function<void(bool)> finish, QWidget *parent = nullptr);
};