Photoshop PSD import (Compositor 1.2.0, part one)
This commit is contained in:
1 parent
3d56688ce4
commit
2cd0d1b86d
41 files changed
+3912
-31
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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); });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 ¬e : 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)};
|
||||
}
|
||||
@@ -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 = {});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
Reference in new issue
Block a user