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

+6 -2
View File
@@ -300,7 +300,10 @@ void ContentViewTests::theImporterPicksFilesAndClearsItsFlag()
desk.note = [&] {
session.selectTool(NavigationTool::hand);
const QList<QFileDialog *> panels = view.findChildren<QFileDialog *>();
return QString("%1 %2").arg(panels.size()).arg(panels.value(0) && panels.value(0)->fileMode() == QFileDialog::ExistingFiles ? "many" : "one");
return QString("%1 %2 %3")
.arg(panels.size())
.arg(panels.value(0) && panels.value(0)->fileMode() == QFileDialog::ExistingFiles ? "many" : "one")
.arg(panels.value(0) ? panels.value(0)->nameFilters().join(';') : QString());
};
session.setShowsImporter(true);
QTRY_COMPARE(desk.seen.size(), 1);
@@ -319,7 +322,8 @@ void ContentViewTests::theImporterPicksFilesAndClearsItsFlag()
QTest::qWait(50);
QCOMPARE(int(session.document().value().layers.size()), 2);
desk.typeBeforeCancel.clear();
QCOMPARE(desk.seen.mid(0, 2), (QStringList{"panel|Open|open|file|||1 many", "panel|Open|open|file|||1 many"}));
const QString panel = "panel|Open|open|file|||1 many Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd)";
QCOMPARE(desk.seen.mid(0, 2), (QStringList{panel, panel}));
QCOMPARE(desk.seen.size(), 3);
// The flag cleared from elsewhere takes the panel down.
desk.replies.clear();
+1 -1
View File
@@ -70,7 +70,7 @@ void DesktopIntegrationTests::theEntryOpensSwiftsDocumentTypes()
QCOMPARE(keys.value("Terminal"), QString("false"));
QVERIFY(keys.value("Categories").split(QLatin1Char(';')).contains("Graphics"));
// Swift's two document types: its projects, then four pictures.
QCOMPARE(keys.value("MimeType"), QString("application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;"));
QCOMPARE(keys.value("MimeType"), QString("application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;"));
}
void DesktopIntegrationTests::aProjectFolderIsAType()
+65 -15
View File
@@ -1,5 +1,6 @@
#include "ContentView.h"
#include "IO/ImageFileDrop.h"
#include "PSDFixture.h"
#include "UI/ProjectTabs.h"
#include "UI/ProjectWorkspaceView.h"
#include <QDragEnterEvent>
@@ -48,6 +49,28 @@ QMimeData *files(const QStringList &paths, const QList<QUrl> &others = {})
return data;
}
// Each dropped picture's copy, in a temporary folder.
QStringList droppedCopies()
{
QStringList found;
for (const QString &folder : QDir::temp().entryList(QDir::Dirs | QDir::NoDotAndDotDot))
for (const QString &file : QDir(QDir::temp().filePath(folder)).entryList({"Dropped.*"}, QDir::Files))
found << QDir::temp().filePath(folder + "/" + file);
return found;
}
// The copy one drop made, taken away again.
QString takeCopy(const QStringList &before)
{
QStringList made = droppedCopies();
for (const QString &old : before)
made.removeAll(old);
if (made.size() != 1)
throw std::runtime_error("a drop made no single copy");
QDir(QFileInfo(made.front()).path()).removeRecursively();
return QFileInfo(made.front()).fileName();
}
QStringList names(const EditorSession &session)
{
QStringList listed;
@@ -96,20 +119,40 @@ void ImageFileDropTests::aPictureIsCopiedToAFileFirst()
data.setData("image/png", png);
EditorSession session;
bool done = false;
const QStringList before = droppedCopies();
ImageFileDrop::importProviders(data, session, QPointF(2, 2), nullptr, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QVERIFY(!session.importError());
const QStringList listed = names(session);
QCOMPARE(listed.size(), 1);
QVERIFY(listed.value(0).startsWith("Dropped-"));
// The copy keeps the layer's name, in its own folder.
QCOMPARE(names(session), QStringList{"Dropped"});
QCOMPARE(session.document().value().size(), QSize(3, 5));
QVERIFY(QFileInfo::exists(QDir::temp().filePath(listed.value(0) + ".png")));
QFile::remove(QDir::temp().filePath(listed.value(0) + ".png"));
QCOMPARE(takeCopy(before), QString("Dropped.png"));
// A Photoshop picture comes after TIFF and opens.
PSDRecord sky;
sky.id = QUuid::createUuid();
sky.name = "Sky";
sky.image = PSDFixture::colorImage(4, 2, 0, 0, 1);
sky.bounds = QRectF(0, 0, 4, 2);
QMimeData photoshop;
photoshop.setData("image/vnd.adobe.photoshop", PSDFixture::data(PSDDocument{4, 2, 72, {sky}}, *sky.image));
photoshop.setData("application/x-other", "ignored");
QCOMPARE(ImageFileDrop::providers(photoshop).front()->formats(), QStringList{"image/vnd.adobe.photoshop"});
photoshop.setData("image/tiff", "tiff first");
QCOMPARE(ImageFileDrop::providers(photoshop).front()->formats(), QStringList{"image/tiff"});
photoshop.removeFormat("image/tiff");
EditorSession opened;
done = false;
ImageFileDrop::importProviders(photoshop, opened, std::nullopt, nullptr, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QVERIFY(!opened.importError());
QCOMPARE(names(opened), QStringList{"Sky"});
QCOMPARE(opened.history.undoName(), QString("Import Photoshop File"));
QCOMPARE(takeCopy(before), QString("Dropped.psd"));
}
void ImageFileDropTests::whatCannotBeReadIsSaid()
{
const QString said = "Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, or TIFF files from the file manager.";
const QString said = "Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) files from the file manager.";
EditorSession session;
bool done = false;
std::unique_ptr<QMimeData> link(files({}, {QUrl("https://example.com/page")}));
@@ -270,8 +313,15 @@ void ImageFileDropTests::aPictureThatCannotBeKeptIsUnreadable()
QVERIFY(png.size() > 4096);
QMimeData data;
data.setData("image/png", png);
const auto dropped = [] { return QDir::temp().entryList({"Dropped-*"}, QDir::Files).size(); };
const qsizetype before = dropped();
// No copy stays behind, and no folder named for one.
const auto dropped = [] {
QStringList kept = droppedCopies();
for (const QString &folder : QDir::temp().entryList(QDir::Dirs | QDir::NoDotAndDotDot))
if (!QUuid::fromString(folder).isNull())
kept << folder;
return kept;
};
const QStringList before = dropped();
EditorSession session;
bool done = false;
signal(SIGXFSZ, SIG_IGN);
@@ -283,7 +333,7 @@ void ImageFileDropTests::aPictureThatCannotBeKeptIsUnreadable()
ImageFileDrop::importProviders(data, session, std::nullopt, nullptr, std::nullopt, [&done] { done = true; });
QCOMPARE(setrlimit(RLIMIT_FSIZE, &room), 0);
QTRY_VERIFY(done);
QCOMPARE(session.importError(), std::optional<QString>("Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, or TIFF files from the file manager."));
QCOMPARE(session.importError(), std::optional<QString>("Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) files from the file manager."));
QVERIFY(!session.document());
QCOMPARE(dropped(), before);
}
@@ -304,13 +354,12 @@ void ImageFileDropTests::aPictureReachesATabWhole()
EditorSession &session = workspace.current().session;
session.createDocument(8, 8);
bool done = false;
const QStringList before = droppedCopies();
workspace.receiveProviders(data, workspace.current().id, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QVERIFY(!session.importError());
const QStringList listed = names(session);
QCOMPARE(listed.size(), 1);
QVERIFY(listed.value(0).startsWith("Dropped-"));
QFile::remove(QDir::temp().filePath(listed.value(0) + ".png"));
QCOMPARE(names(session), QStringList{"Dropped"});
QCOMPARE(takeCopy(before), QString("Dropped.png"));
}
namespace {
@@ -344,11 +393,12 @@ void ImageFileDropTests::aLazyDragGivesOnlyThePictureChosen()
EditorSession &session = workspace.current().session;
session.createDocument(8, 8);
bool done = false;
const QStringList before = droppedCopies();
workspace.receiveProviders(drag, workspace.current().id, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QVERIFY(!session.importError());
QCOMPARE(names(session).size(), 1);
QFile::remove(QDir::temp().filePath(names(session).value(0) + ".png"));
QCOMPARE(names(session), QStringList{"Dropped"});
QCOMPARE(takeCopy(before), QString("Dropped.png"));
drag.asked.removeDuplicates();
drag.asked.sort();
QCOMPARE(drag.asked, (QStringList{"image/png", "text/uri-list"}));
+1 -1
View File
@@ -7,7 +7,7 @@
// The session cases of Swift's ImageImportTests, and the queue.
namespace {
const QString unreadable = QStringLiteral("The image could not be read. It may be damaged or unavailable.");
const QString unsupported = QStringLiteral("Choose a JPEG, PNG, HEIC, or TIFF image.");
const QString unsupported = QStringLiteral("Choose a JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) file.");
const QString tooLarge = QStringLiteral("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit.");
// A 64 by 32 PNG, as Swift's fixture is.
+1 -1
View File
@@ -459,7 +459,7 @@ void ImageImportTests::thumbnailsAreSmoothed()
void ImageImportTests::errorsCarrySwiftsDescriptions()
{
QCOMPARE(QString(ImageImportError(ImageImportError::Kind::unreadable).what()), QString("The image could not be read. It may be damaged or unavailable."));
QCOMPARE(QString(ImageImportError(ImageImportError::Kind::unsupported).what()), QString("Choose a JPEG, PNG, HEIC, or TIFF image."));
QCOMPARE(QString(ImageImportError(ImageImportError::Kind::unsupported).what()), QString("Choose a JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) file."));
QCOMPARE(QString(ImageImportError(ImageImportError::Kind::tooLarge).what()),
QString("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit."));
QCOMPARE(ImageImportError(ImageImportError::Kind::tooLarge).kind, ImageImportError::Kind::tooLarge);
+361
View File
@@ -0,0 +1,361 @@
#pragma once
#include "Document/BrushStroke.h"
#include "IO/PSD/PSDTypes.h"
#include <QPainter>
// Swift's PSDFixture: tiny Photoshop files; the app writes none.
namespace PSDFixture {
struct Buffer {
QByteArray data;
void u8(uchar value) { data.append(char(value)); }
void u16(quint16 value)
{
u8(uchar(value >> 8));
u8(uchar(value));
}
void i16(qint16 value) { u16(quint16(value)); }
void u32(quint32 value)
{
u16(quint16(value >> 16));
u16(quint16(value));
}
void i32(qint32 value) { u32(quint32(value)); }
void bytes(const QByteArray &value) { data.append(value); }
void string(const char *value) { data.append(value); }
};
struct Planes {
std::vector<uchar> red, green, blue, alpha;
};
struct Channel {
qint16 id;
QByteArray payload;
};
struct Prepared {
PSDRecord record;
bool isDivider = false;
std::vector<Channel> channels;
int top = 0, left = 0, bottom = 0, right = 0;
int maskTop = 0, maskLeft = 0, maskBottom = 0, maskRight = 0;
};
// Straight colour from premultiplied pixels, rounded as Swift rounds.
inline Planes planes(const QImage &image)
{
const QImage pixels = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
Planes result;
for (int y = 0; y < pixels.height(); ++y) {
const uchar *row = pixels.constScanLine(y);
for (int x = 0; x < pixels.width(); ++x) {
const int r = row[x * 4], g = row[x * 4 + 1], b = row[x * 4 + 2], a = row[x * 4 + 3];
result.alpha.push_back(uchar(a));
const auto straight = [a](int value) { return a == 0 ? uchar(0) : uchar(std::min(255, (value * 255 + a / 2) / a)); };
result.red.push_back(straight(r));
result.green.push_back(straight(g));
result.blue.push_back(straight(b));
}
}
return result;
}
inline std::vector<uchar> grayPlane(const QImage &image)
{
const QImage gray = image.convertToFormat(QImage::Format_Grayscale8);
std::vector<uchar> plane;
for (int y = 0; y < gray.height(); ++y)
plane.insert(plane.end(), gray.constScanLine(y), gray.constScanLine(y) + gray.width());
return plane;
}
inline QByteArray packBits(const std::vector<uchar> &row)
{
QByteArray output;
size_t i = 0;
while (i < row.size()) {
if (i + 1 < row.size() && row[i] == row[i + 1]) {
size_t run = 2;
while (i + run < row.size() && row[i + run] == row[i] && run < 128)
++run;
output.append(char(qint8(1 - int(run))));
output.append(char(row[i]));
i += run;
} else {
const size_t start = i++;
while (i < row.size() && i - start < 128) {
if (i + 1 < row.size() && row[i] == row[i + 1])
break;
++i;
}
output.append(char(i - start - 1));
output.append(reinterpret_cast<const char *>(row.data() + start), qsizetype(i - start));
}
}
return output;
}
// PackBits by row: the counts, then the rows.
inline QByteArray encode(const std::vector<uchar> &plane, int width, int height)
{
Buffer counts;
QByteArray packed;
for (int row = 0; row < height; ++row) {
const QByteArray line = packBits(std::vector<uchar>(plane.begin() + row * width, plane.begin() + (row + 1) * width));
counts.u16(quint16(line.size()));
packed.append(line);
}
return counts.data + packed;
}
inline QByteArray channelPayload(const std::vector<uchar> &plane, int width, int height)
{
return QByteArray("\0\1", 2) + encode(plane, width, height);
}
inline std::vector<Channel> emptyChannels()
{
const QByteArray raw("\0\0", 2);
return {{-1, raw}, {0, raw}, {1, raw}, {2, raw}};
}
inline Prepared layer(const PSDRecord &record)
{
const int width = record.image ? record.image->width() : 0, height = record.image ? record.image->height() : 0;
const int left = int(std::round(record.bounds.left())), top = int(std::round(record.bounds.top()));
Prepared prepared;
prepared.record = record;
if (record.image && width > 0 && height > 0) {
const Planes split = planes(*record.image);
for (const auto &[id, plane] : {std::pair<qint16, const std::vector<uchar> &>(-1, split.alpha), {0, split.red}, {1, split.green}, {2, split.blue}})
prepared.channels.push_back({id, channelPayload(plane, width, height)});
} else {
prepared.channels = emptyChannels();
}
if (record.mask)
prepared.channels.push_back({-2, channelPayload(grayPlane(*record.mask), record.mask->width(), record.mask->height())});
prepared.top = top;
prepared.left = left;
prepared.bottom = top + height;
prepared.right = left + width;
prepared.maskTop = top;
prepared.maskLeft = left;
prepared.maskBottom = top + (record.mask ? record.mask->height() : 0);
prepared.maskRight = left + (record.mask ? record.mask->width() : 0);
return prepared;
}
inline Prepared emptyLayer(const QString &name, const QString &blendKey, int section, std::optional<QUuid> parent, const PSDRecord *group = nullptr)
{
PSDRecord record;
record.id = group ? group->id : QUuid::createUuid();
record.parentID = parent;
record.name = name;
record.isGroup = section != 3;
record.isVisible = group ? group->isVisible : true;
record.opacity = group ? group->opacity : 1;
record.blendKey = blendKey;
record.mask = group ? group->mask : std::nullopt;
record.maskEnabled = group ? group->maskEnabled : true;
record.kind = PSDLayerKind::group;
Prepared prepared;
prepared.record = record;
prepared.isDivider = section == 3;
prepared.channels = emptyChannels();
if (record.mask) {
prepared.channels.push_back({-2, channelPayload(grayPlane(*record.mask), record.mask->width(), record.mask->height())});
prepared.maskBottom = record.mask->height();
prepared.maskRight = record.mask->width();
}
return prepared;
}
inline void additional(Buffer &buffer, const char *key, const QByteArray &payload)
{
buffer.string("8BIM");
buffer.string(key);
buffer.u32(quint32(payload.size()));
buffer.bytes(payload);
if (payload.size() % 2 == 1)
buffer.u8(0);
}
inline QByteArray luni(const QString &name)
{
Buffer data;
data.u32(quint32(name.size()));
for (const QChar unit : name)
data.u16(unit.unicode());
return data.data;
}
inline QByteArray extraData(const Prepared &item)
{
Buffer extra;
if (item.record.mask && item.maskRight > item.maskLeft && item.maskBottom > item.maskTop) {
extra.u32(20);
extra.i32(item.maskTop);
extra.i32(item.maskLeft);
extra.i32(item.maskBottom);
extra.i32(item.maskRight);
extra.u8(255);
extra.u8(uchar((item.record.maskLinked ? 0 : 1) | (item.record.maskEnabled ? 0 : 2)));
extra.u16(0);
} else {
extra.u32(0);
}
extra.u32(0);
const QByteArray pascal = item.record.name.toUtf8().left(255);
extra.u8(uchar(pascal.size()));
extra.bytes(pascal);
extra.bytes(QByteArray((4 - ((1 + pascal.size()) % 4)) % 4, '\0'));
additional(extra, "luni", luni(item.record.name));
if (item.record.isGroup || item.isDivider) {
Buffer payload;
payload.u32(item.isDivider ? 3 : 1);
payload.string("8BIM");
payload.bytes((item.isDivider ? QStringLiteral("norm") : item.record.blendKey + QStringLiteral(" ")).left(4).toLatin1());
additional(extra, "lsct", payload.data);
}
return extra.data;
}
inline void writeRecord(Buffer &buffer, const Prepared &item)
{
buffer.i32(item.top);
buffer.i32(item.left);
buffer.i32(item.bottom);
buffer.i32(item.right);
buffer.u16(quint16(item.channels.size()));
for (const Channel &channel : item.channels) {
buffer.i16(channel.id);
buffer.u32(quint32(channel.payload.size()));
}
buffer.string("8BIM");
buffer.bytes((item.record.blendKey + QStringLiteral(" ")).left(4).toLatin1());
buffer.u8(uchar(std::clamp(int(std::round(item.record.opacity * 255)), 0, 255)));
buffer.u8(item.record.clipping ? 1 : 0);
buffer.u8(item.record.isVisible ? 0 : 2);
buffer.u8(0);
const QByteArray extra = extraData(item);
buffer.u32(quint32(extra.size()));
buffer.bytes(extra);
}
inline void emitLayers(const PSDDocument &document, std::optional<QUuid> parent, std::vector<Prepared> &prepared)
{
// File order runs bottom to top: divider, children, folder.
for (const PSDRecord &record : document.layers) {
if (record.parentID != parent)
continue;
if (record.isGroup) {
prepared.push_back(emptyLayer(QStringLiteral("</Layer group>"), QStringLiteral("norm"), 3, parent));
emitLayers(document, record.id, prepared);
prepared.push_back(emptyLayer(record.name, record.blendKey, 1, record.parentID, &record));
} else {
prepared.push_back(layer(record));
}
}
}
inline QByteArray layerSection(const PSDDocument &document)
{
std::vector<Prepared> prepared;
emitLayers(document, std::nullopt, prepared);
Buffer records, payloads;
records.i16(qint16(prepared.size()));
for (const Prepared &item : prepared) {
writeRecord(records, item);
for (const Channel &channel : item.channels)
payloads.bytes(channel.payload);
}
Buffer info;
info.u32(0);
info.bytes(records.data);
info.bytes(payloads.data);
if (info.data.size() % 2 == 1)
info.u8(0);
Buffer length;
length.u32(quint32(info.data.size() - 4));
info.data.replace(0, 4, length.data);
info.u32(0);
return info.data;
}
inline QByteArray resolutionResource(double resolution)
{
Buffer resource;
resource.string("8BIM");
resource.u16(1005);
resource.u8(0);
resource.u8(0);
resource.u32(16);
const quint32 fixed = quint32(std::round(std::clamp(resolution, 1.0, 9600.0) * 65536));
for (int axis = 0; axis < 2; ++axis) {
resource.u32(fixed);
resource.u16(1);
resource.u16(1);
}
return resource.data;
}
inline QByteArray data(const PSDDocument &document, const QImage &composite)
{
Buffer file;
file.string("8BPS");
file.u16(1);
file.bytes(QByteArray(6, '\0'));
file.u16(4);
file.u32(quint32(document.height));
file.u32(quint32(document.width));
file.u16(8);
file.u16(3);
file.u32(0);
const QByteArray resources = resolutionResource(document.resolution);
file.u32(quint32(resources.size()));
file.bytes(resources);
const QByteArray layers = layerSection(document);
file.u32(quint32(layers.size()));
file.bytes(layers);
// The composite, PackBits by channel.
QImage flattened = BrushRaster::context(document.width, document.height, false);
QPainter painter(&flattened);
BrushRaster::draw(composite, QRectF(0, 0, document.width, document.height), painter);
painter.end();
const Planes split = planes(flattened);
file.u16(1);
QByteArray counts, packed;
for (const std::vector<uchar> *plane : {&split.red, &split.green, &split.blue, &split.alpha}) {
const QByteArray encoded = encode(*plane, document.width, document.height);
counts.append(encoded.left(document.height * 2));
packed.append(encoded.mid(document.height * 2));
}
file.bytes(counts);
file.bytes(packed);
return file.data;
}
inline QImage colorImage(int width, int height, double red, double green, double blue, double alpha = 1)
{
QImage image = BrushRaster::context(width, height, false);
image.fill(QColor::fromRgbF(float(red), float(green), float(blue), float(alpha)));
return image;
}
inline QImage grayImage(int width, int height, int value)
{
QImage image = BrushRaster::context(width, height, true);
image.fill(value);
return image;
}
inline PSDRecord record(const QString &name, std::optional<QImage> image = std::nullopt, QRectF bounds = QRectF())
{
PSDRecord result;
result.id = QUuid::createUuid();
result.name = name;
result.image = image;
result.bounds = bounds;
return result;
}
}
+402
View File
@@ -0,0 +1,402 @@
#include "ContentView.h"
#include "PSDFixture.h"
#include <QLabel>
#include <QProgressBar>
#include <QPushButton>
#include <QScrollArea>
#include <QTemporaryDir>
#include <QtTest>
// A Photoshop file through the session's import and the sheet.
namespace {
PSDRecord layer(const QString &name, QRectF bounds, const char *blend = "norm")
{
PSDRecord record = PSDFixture::record(name, PSDFixture::colorImage(int(bounds.width()), int(bounds.height()), 0, 0, 1), bounds);
record.blendKey = blend;
return record;
}
QUrl write(const QTemporaryDir &folder, const QString &name, const QByteArray &bytes)
{
QFile file(folder.filePath(name));
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size())
throw std::runtime_error("could not write a fixture");
return QUrl::fromLocalFile(file.fileName());
}
QUrl photoshop(const QTemporaryDir &folder, const QString &name, const std::vector<PSDRecord> &layers, QSize size = QSize(4, 2), double dpi = 72)
{
return write(folder, name, PSDFixture::data(PSDDocument{size.width(), size.height(), dpi, layers}, PSDFixture::colorImage(size.width(), size.height(), 0, 0, 0, 0)));
}
QStringList names(const EditorSession &session)
{
QStringList listed;
for (const ImageLayer &each : session.document().value().layers)
listed << each.name;
return listed;
}
void import(EditorSession &session, const QList<QUrl> &urls, std::optional<QPointF> point = std::nullopt)
{
bool done = false;
session.importImages(urls, point, [&done] { done = true; });
QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000));
}
PSDImport blanks(size_t count)
{
PSDImport imported{8, 8, 72, {}, {}};
for (size_t index = 0; index < count; ++index)
imported.layers.push_back(ImageLayer(QStringLiteral("Blank"), QSizeF(8, 8)));
return imported;
}
std::optional<ImageImportError::Kind> refusal(const std::function<void()> &run)
{
try {
run();
} catch (const ImageImportError &error) {
return error.kind;
}
return std::nullopt;
}
}
class PSDImportTests : public QObject {
Q_OBJECT
private slots:
void importCreatesDocumentAndExistingCanvasGetsAGroup();
void cancelledConversionLeavesTheDocumentUnchanged();
void aConfirmedConversionImportsAndCancelWhileReadingStops();
void failuresAreReportedAndStepsNamedByTheFiles();
void aFileLandsAtThePointInsideTheActiveFolder();
void layerLimitsAndDeepFoldersRefuseTheFile();
void theSheetReadsThenListsAndAnswers();
void aFileTheSessionRefusesIsReported();
void aDirectInsertIsOneStepAndFitsTheView();
void theSheetKeepsItsLayoutAndItsContent();
};
void PSDImportTests::importCreatesDocumentAndExistingCanvasGetsAGroup()
{
const QTemporaryDir folder;
const QUrl url = photoshop(folder, "Trip.psd", {layer("Sky", QRectF(0, 0, 4, 2))}, QSize(4, 2), 144);
EditorSession session;
QSignalSpy changed(&session, &EditorSession::changed);
import(session, {url});
QCOMPARE(session.document().value().size(), QSizeF(4, 2));
QCOMPARE(session.document().value().resolution, 144.0);
QCOMPARE(names(session), QStringList{"Sky"});
QVERIFY(!session.importError() && !session.showsConversionSheet() && !session.conversionRequest());
QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[0].id));
QCOMPARE(session.history.undoName(), QString("Import Photoshop File"));
QVERIFY(changed.count() > 0);
import(session, {url});
const std::vector<ImageLayer> &layers = session.document().value().layers;
QCOMPARE(names(session), (QStringList{"Sky", "Trip", "Sky"}));
QVERIFY(layers[1].isGroup && !layers[1].parentID);
QCOMPARE(layers[1].size(), QSizeF(4, 2));
QCOMPARE(layers[2].parentID, std::optional(layers[1].id));
QCOMPARE(session.activeLayerID(), std::optional(layers[1].id));
session.undo();
QCOMPARE(names(session), QStringList{"Sky"});
}
void PSDImportTests::cancelledConversionLeavesTheDocumentUnchanged()
{
const QTemporaryDir folder;
const QUrl url = photoshop(folder, "Vivid.psd", {layer("Vivid", QRectF(0, 0, 2, 2), "vLit")}, QSize(2, 2));
EditorSession session;
bool done = false;
session.importImages({url}, std::nullopt, [&done] { done = true; });
QVERIFY(session.showsConversionSheet() && session.conversionRequest().value().isReading);
QTRY_VERIFY(!session.conversionRequest().value().isReading);
const PSDConversionRequest &request = session.conversionRequest().value();
QCOMPARE(request.title, QString("Open “Vivid.psd”?"));
QCOMPARE(request.confirmTitle, QString("Import"));
QCOMPARE(request.conversions.size(), size_t(1));
QCOMPARE(request.conversions[0].layerName, QString("Vivid"));
QVERIFY(session.isImporting() && !done);
session.finishConversion(false);
QVERIFY(!session.showsConversionSheet() && !session.conversionRequest());
QTRY_VERIFY(done);
QVERIFY(!session.document() && !session.isImporting() && !session.importError());
}
void PSDImportTests::aConfirmedConversionImportsAndCancelWhileReadingStops()
{
const QTemporaryDir folder;
const QUrl url = photoshop(folder, "Vivid.psd", {layer("Vivid", QRectF(0, 0, 2, 2), "vLit")}, QSize(2, 2));
EditorSession session;
bool done = false;
session.importImages({url}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(session.conversionRequest() && !session.conversionRequest().value().isReading);
session.finishConversion(true);
QTRY_VERIFY(done);
QCOMPARE(names(session), QStringList{"Vivid"});
QVERIFY(session.document().value().layers[0].blendMode == LayerBlendMode::normal);
// Cancel while reading: nothing lands, nothing is said.
EditorSession stopped;
done = false;
stopped.importImages({url}, std::nullopt, [&done] { done = true; });
QVERIFY(stopped.conversionRequest().value().isReading);
stopped.finishConversion(false);
QVERIFY(!stopped.showsConversionSheet());
QTRY_VERIFY(done);
QVERIFY(!stopped.document() && !stopped.importError() && !stopped.showsConversionSheet());
// Nothing to convert: the sheet goes unasked.
const QUrl plain = photoshop(folder, "Plain.psd", {layer("Plain", QRectF(0, 0, 4, 2))});
done = false;
stopped.importImages({plain}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(names(stopped), QStringList{"Plain"});
}
void PSDImportTests::failuresAreReportedAndStepsNamedByTheFiles()
{
const QTemporaryDir folder;
const QUrl broken = write(folder, "Broken.psd", PSDFixture::data(PSDDocument{4, 2, 72, {}}, PSDFixture::colorImage(4, 2, 0, 0, 0)).left(40));
EditorSession session;
import(session, {broken});
QCOMPARE(session.importError(), std::optional<QString>("Broken.psd: The Photoshop file could not be read. It may be damaged or incomplete."));
QVERIFY(!session.document() && !session.showsConversionSheet());
session.setImportError(std::nullopt);
// A request of other files keeps the images' step name.
QImage png(3, 3, QImage::Format_RGBA8888);
png.fill(Qt::red);
QVERIFY(png.save(folder.filePath("Red.png")));
const QUrl url = photoshop(folder, "Sky.psd", {layer("Sky", QRectF(0, 0, 4, 2))});
import(session, {QUrl::fromLocalFile(folder.filePath("Red.png")), url});
QCOMPARE(session.history.undoName(), QString("Import Images"));
QCOMPARE(names(session), (QStringList{"Red", "Sky", "Sky"}));
}
void PSDImportTests::aFileLandsAtThePointInsideTheActiveFolder()
{
const QTemporaryDir folder;
const QUrl url = photoshop(folder, "Pair.psd", {layer("Left", QRectF(0, 0, 2, 2)), layer("Right", QRectF(6, 4, 2, 2))}, QSize(8, 8));
EditorSession session;
session.createDocument(100, 100);
session.addGroup();
const QUuid active = session.activeLayerID().value();
session.toggleGroupExpansion(active);
QVERIFY(session.collapsedGroupIDs().contains(active));
import(session, {url}, QPointF(50, 50));
const std::vector<ImageLayer> &layers = session.document().value().layers;
QCOMPARE(names(session), (QStringList{"Folder 1", "Pair", "Left", "Right"}));
QCOMPARE(layers[1].parentID, std::optional(active));
QCOMPARE(layers[2].parentID, std::optional(layers[1].id));
// The pixels, (0, 0) to (8, 6), centre there.
QCOMPARE(layers[2].origin(), QPointF(46, 47));
QCOMPARE(layers[3].origin(), QPointF(52, 51));
QVERIFY(!session.collapsedGroupIDs().contains(active) && !session.collapsedGroupIDs().contains(layers[1].id));
QCOMPARE(session.activeLayerID(), std::optional(layers[1].id));
// Beside an active layer inside a folder, within that folder.
session.selectLayer(layers[2].id);
import(session, {url});
const std::vector<ImageLayer> &again = session.document().value().layers;
QCOMPARE(again[again.size() - 3].name, QString("Pair"));
QCOMPARE(again[again.size() - 3].parentID, std::optional(layers[1].id));
}
void PSDImportTests::layerLimitsAndDeepFoldersRefuseTheFile()
{
EditorSession session;
QCOMPARE(refusal([&] { session.insertPhotoshop(blanks(10'001), "Many"); }), std::optional(ImageImportError::Kind::tooLarge));
session.insertPhotoshop(blanks(10'000), "Many");
QCOMPARE(session.document().value().layers.size(), size_t(10'000));
QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers.back().id));
EditorSession open;
open.createDocument(8, 8);
QCOMPARE(refusal([&] { open.insertPhotoshop(blanks(10'000), "Many"); }), std::optional(ImageImportError::Kind::tooLarge));
open.insertPhotoshop(blanks(9'999), "Many");
QCOMPARE(open.document().value().layers.size(), size_t(10'000));
// Folders nested past the hierarchy's depth refuse the whole file.
PSDImport deep = blanks(0);
std::optional<QUuid> parent;
for (int depth = 0; depth < 65; ++depth) {
ImageLayer folder(QStringLiteral("Folder"), QSizeF(8, 8));
folder.isGroup = true;
folder.parentID = parent;
parent = folder.id;
deep.layers.push_back(folder);
}
EditorSession refused;
try {
refused.insertPhotoshop(deep, "Deep");
QFAIL("a hierarchy past its depth was taken");
} catch (const PSDError &error) {
QVERIFY(error.kind == PSDError::Kind::truncated);
}
QVERIFY(!refused.document());
deep.layers.pop_back();
refused.insertPhotoshop(deep, "Deep");
QCOMPARE(refused.document().value().layers.size(), size_t(64));
QCOMPARE(refused.activeLayerID(), std::optional(deep.layers.front().id));
}
void PSDImportTests::theSheetReadsThenListsAndAnswers()
{
EditorSession session;
ContentView view(session);
view.resize(900, 600);
view.show();
QVERIFY(QTest::qWaitForWindowExposed(&view));
session.beginPSDReading("Open “Trip.psd”?", "Import");
QDialog *sheet = view.findChild<QDialog *>("conversionSheet");
QVERIFY(sheet && sheet->isVisible() && sheet->windowModality() == Qt::WindowModal);
QCOMPARE(sheet->windowTitle(), QString("Open “Trip.psd”?"));
QVERIFY(sheet->findChild<QProgressBar *>("conversionSpinner"));
QVERIFY(!sheet->findChild<QPushButton *>("conversionConfirm")->isEnabled());
QCOMPARE(sheet->findChild<QPushButton *>("conversionConfirm")->text(), QString("Import"));
QStringList words;
for (const QLabel *label : sheet->findChildren<QLabel *>())
words << label->text();
QVERIFY(words.contains("Reading the file to see what needs converting.") && words.contains("Reading the Photoshop file…"));
// The list replaces the reading row; Return answers yes.
std::optional<bool> answer;
session.finishPSDReading({PSDConversion{.layerName = "Vivid", .message = "Blend mode “vLit” isn’t supported."}}, [&answer](bool yes) { answer = yes; });
QCOMPARE(view.findChild<QDialog *>("conversionSheet"), sheet);
QTRY_VERIFY(!sheet->findChild<QProgressBar *>("conversionSpinner"));
QScrollArea *list = sheet->findChild<QScrollArea *>("conversionList");
QVERIFY(list);
words.clear();
for (const QLabel *label : list->findChildren<QLabel *>())
words << label->text();
QCOMPARE(words, (QStringList{"Vivid", "Blend mode “vLit” isn’t supported."}));
QVERIFY(sheet->findChild<QPushButton *>("conversionConfirm")->isEnabled());
QTest::keyClick(sheet, Qt::Key_Return);
QTRY_COMPARE(answer, std::optional(true));
QVERIFY(!session.showsConversionSheet());
QTRY_VERIFY(!view.findChild<QDialog *>("conversionSheet"));
// Escape and Cancel answer no.
for (const bool escape : {true, false}) {
answer = std::nullopt;
session.beginPSDReading("Open “Trip.psd”?", "Import");
session.finishPSDReading({PSDConversion{.layerName = "Words", .message = "Text"}}, [&answer](bool yes) { answer = yes; });
QDialog *shown = view.findChild<QDialog *>("conversionSheet");
QTRY_VERIFY(shown && shown->findChild<QScrollArea *>("conversionList"));
if (escape)
QTest::keyClick(shown, Qt::Key_Escape);
else
shown->findChild<QPushButton *>("conversionCancel")->click();
QTRY_COMPARE(answer, std::optional(false));
QTRY_VERIFY(!view.findChild<QDialog *>("conversionSheet"));
}
// Taken away by the session: the sheet goes, answering nothing.
session.beginPSDReading("Open “Trip.psd”?", "Import");
QVERIFY(view.findChild<QDialog *>("conversionSheet"));
session.endPSDReading();
QTRY_VERIFY(!view.findChild<QDialog *>("conversionSheet"));
QVERIFY(!session.showsConversionSheet());
}
void PSDImportTests::aFileTheSessionRefusesIsReported()
{
const QTemporaryDir folder;
const QUrl url = photoshop(folder, "Sky.psd", {layer("Sky", QRectF(0, 0, 4, 2))});
EditorSession session;
session.insertPhotoshop(blanks(9'999), "Many");
import(session, {url});
QCOMPARE(session.importError(),
std::optional<QString>("Sky.psd: This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit."));
QCOMPARE(session.document().value().layers.size(), size_t(9'999));
}
void PSDImportTests::aDirectInsertIsOneStepAndFitsTheView()
{
EditorSession session;
session.viewport.viewSize = QSizeF(400, 200);
PSDImport imported = blanks(2);
imported.resolution = 300;
session.insertPhotoshop(imported, "Blanks");
CanvasViewport fitted;
fitted.viewSize = QSizeF(400, 200);
fitted.fit(QSizeF(8, 8));
QVERIFY(session.viewport == fitted);
QCOMPARE(session.document().value().resolution, 300.0);
QCOMPARE(session.history.undoName(), QString("Import Photoshop File"));
session.undo();
QVERIFY(!session.document());
session.redo();
QCOMPARE(names(session), (QStringList{"Blank", "Blank"}));
}
void PSDImportTests::theSheetKeepsItsLayoutAndItsContent()
{
EditorSession session;
session.createDocument(8, 8);
ContentView view(session);
view.resize(900, 600);
view.show();
QVERIFY(QTest::qWaitForWindowExposed(&view));
session.beginPSDReading("Open “Trip.psd”?", "Import");
QDialog *sheet = view.findChild<QDialog *>("conversionSheet");
QVERIFY(sheet);
PSDConversionSheet *reading = sheet->findChild<PSDConversionSheet *>();
QTRY_VERIFY(reading && reading->isVisible());
QCOMPARE(reading->minimumSize(), QSize(520, 360));
QVERIFY(sheet->width() >= 520 && sheet->height() >= 360);
QCOMPARE(reading->layout()->contentsMargins(), QMargins(24, 24, 24, 24));
QCOMPARE(reading->layout()->spacing(), 16);
const QProgressBar *spinner = sheet->findChild<QProgressBar *>("conversionSpinner");
QVERIFY(spinner->minimum() == 0 && spinner->maximum() == 0 && !spinner->isTextVisible() && spinner->width() == 16);
QCOMPARE(spinner->parentWidget()->minimumHeight(), 180);
// Unrelated changes keep the content; the spinner is centred.
session.selectTool(NavigationTool::hand);
QCOMPARE(sheet->findChildren<PSDConversionSheet *>(), QList<PSDConversionSheet *>{reading});
const QRect row = spinner->parentWidget()->rect();
const QLabel *words = spinner->parentWidget()->findChild<QLabel *>();
QCOMPARE(words->geometry().left() - spinner->geometry().right() - 1, 10);
QVERIFY(std::abs(spinner->geometry().left() - (row.width() - 1 - words->geometry().right())) <= 1);
// A title as text, not markup, bold at 17 pixels.
std::optional<bool> answer;
session.finishPSDReading({PSDConversion{.layerName = "<b>Bold</b>", .message = "A note that runs long enough to wrap over the sheet's width, "
"which the list keeps whole instead of cutting it off at the edge."}},
[&answer](bool yes) { answer = yes; });
// The reading content hides at once, before it is deleted.
QVERIFY(!reading->isVisible());
PSDConversionSheet *listed = nullptr;
QTRY_VERIFY((listed = [&] {
for (PSDConversionSheet *each : sheet->findChildren<PSDConversionSheet *>())
if (each->isVisible())
return each;
return static_cast<PSDConversionSheet *>(nullptr);
}()) != nullptr && listed != reading);
int visible = 0;
for (PSDConversionSheet *each : sheet->findChildren<PSDConversionSheet *>())
visible += each->isVisible();
QCOMPARE(visible, 1);
const QList<QLabel *> labels = listed->findChildren<QLabel *>();
const QLabel *title = labels.value(0);
QCOMPARE(title->text(), QString("Open “Trip.psd”?"));
QCOMPARE(title->font().pixelSize(), 17);
QCOMPARE(title->font().weight(), QFont::Bold);
QCOMPARE(labels.value(1)->foregroundRole(), QPalette::PlaceholderText);
const QScrollArea *list = listed->findChild<QScrollArea *>("conversionList");
QVERIFY(list->widgetResizable() && list->minimumHeight() == 180 && list->widget());
const QLabel *name = list->widget()->findChildren<QLabel *>().value(0), *note = list->widget()->findChildren<QLabel *>().value(1);
QCOMPARE(name->textFormat(), Qt::PlainText);
QVERIFY(name->wordWrap() && note->wordWrap());
QCOMPARE(name->font().weight(), QFont::Bold);
QCOMPARE(note->font().pixelSize(), 13);
// Rows stack in the list, the name above its note.
QCOMPARE(note->geometry().top() - name->geometry().bottom() - 1, 4);
QCOMPARE(name->geometry().top(), list->widget()->layout()->contentsMargins().top() + 4);
QCOMPARE(name->geometry().left(), list->widget()->layout()->contentsMargins().left() + 8);
QVERIFY(note->height() > note->fontMetrics().height());
// Rows keep the height their words need, no more.
QCOMPARE(note->height(), note->heightForWidth(note->width()));
// Cancel stands left of confirm, both at the right.
const QPushButton *cancel = listed->findChild<QPushButton *>("conversionCancel"), *confirm = listed->findChild<QPushButton *>("conversionConfirm");
QVERIFY(!cancel->autoDefault() && confirm->isDefault());
QVERIFY(cancel->geometry().right() < confirm->geometry().left());
QCOMPARE(confirm->geometry().right(), listed->width() - 25);
QVERIFY(cancel->geometry().left() > listed->width() / 2);
session.finishConversion(false);
QTRY_COMPARE(answer, std::optional(false));
}
QTEST_MAIN(PSDImportTests)
#include "PSDImportTests.moc"
+130
View File
@@ -0,0 +1,130 @@
#pragma once
#include "PSDFixture.h"
// Raw Photoshop records built byte by byte for edges.
namespace PSDReaderFixtures {
struct Extra {
const char *signature;
const char *key;
QByteArray payload;
// An 8B64 block's upper length word.
quint32 high = 0;
};
struct Record {
QRect bounds;
std::vector<PSDFixture::Channel> channels;
const char *signature = "8BIM";
const char *blendKey = "norm";
uchar opacity = 255;
bool clipping = false;
uchar flags = 0;
std::optional<QRect> mask;
uchar maskFlags = 0;
QByteArray name = "Layer";
std::vector<Extra> extras;
};
inline QByteArray solidChannels(const QRect &bounds, std::vector<PSDFixture::Channel> &channels)
{
const QByteArray plane(bounds.width() * bounds.height(), '\x80');
for (const qint16 id : {-1, 0, 1, 2})
channels.push_back({id, QByteArray("\0\0", 2) + plane});
return plane;
}
inline QByteArray header(quint32 width, quint32 height)
{
PSDFixture::Buffer data;
data.string("8BPS");
data.u16(1);
data.bytes(QByteArray(6, '\0'));
data.u16(3);
data.u32(height);
data.u32(width);
data.u16(8);
data.u16(3);
data.u32(0);
return data.data;
}
inline QByteArray file(const std::vector<Record> &records, const QByteArray &resources = QByteArray(), quint32 width = 8, quint32 height = 8,
std::optional<qint16> count = std::nullopt)
{
PSDFixture::Buffer data;
data.bytes(header(width, height));
data.u32(quint32(resources.size()));
data.bytes(resources);
PSDFixture::Buffer body, payloads;
body.i16(count.value_or(qint16(records.size())));
for (const Record &record : records) {
body.i32(record.bounds.top());
body.i32(record.bounds.left());
body.i32(record.bounds.top() + record.bounds.height());
body.i32(record.bounds.left() + record.bounds.width());
body.u16(quint16(record.channels.size()));
for (const PSDFixture::Channel &channel : record.channels) {
body.i16(channel.id);
body.u32(quint32(channel.payload.size()));
payloads.bytes(channel.payload);
}
body.string(record.signature);
body.string(record.blendKey);
body.u8(record.opacity);
body.u8(record.clipping ? 1 : 0);
body.u8(record.flags);
body.u8(0);
PSDFixture::Buffer extra;
if (record.mask) {
extra.u32(20);
extra.i32(record.mask->top());
extra.i32(record.mask->left());
extra.i32(record.mask->top() + record.mask->height());
extra.i32(record.mask->left() + record.mask->width());
extra.u8(255);
extra.u8(record.maskFlags);
extra.u16(0);
} else {
extra.u32(0);
}
extra.u32(0);
extra.u8(uchar(record.name.size()));
extra.bytes(record.name);
extra.bytes(QByteArray((4 - ((record.name.size() + 1) % 4)) % 4, '\0'));
for (const Extra &block : record.extras) {
extra.string(block.signature);
extra.string(block.key);
if (QByteArray(block.signature) == "8B64")
extra.u32(block.high);
extra.u32(quint32(block.payload.size()));
extra.bytes(block.payload);
if (block.payload.size() % 2 == 1)
extra.u8(0);
}
body.u32(quint32(extra.data.size()));
body.bytes(extra.data);
}
PSDFixture::Buffer info;
info.u32(quint32(body.data.size() + payloads.data.size()));
info.bytes(body.data);
info.bytes(payloads.data);
info.u32(0);
data.u32(quint32(info.data.size()));
data.bytes(info.data);
return data.data;
}
inline QByteArray section(quint32 type, const char *blend = "norm")
{
PSDFixture::Buffer payload;
payload.u32(type);
payload.string("8BIM");
payload.string(blend);
return payload.data;
}
inline QByteArray unicode(const QString &name)
{
return PSDFixture::luni(name);
}
}
+223
View File
@@ -0,0 +1,223 @@
#include "IO/PSD/PSDChannelCoder.h"
#include "IO/PSD/PSDDocumentBuilder.h"
#include "IO/PSD/PSDReader.h"
#include "PSDReaderFixtures.h"
#include "PSDVectorBuilders.h"
#include "PSDVectorFixtures.h"
#include <QTemporaryDir>
#include <sys/stat.h>
#include <thread>
#include <QtTest>
// The reader's bounds and budgets, and vector layers read whole.
using PSDReaderFixtures::Record;
namespace {
template <typename Error> std::optional<typename Error::Kind> refusal(const std::function<void()> &run)
{
try {
run();
} catch (const Error &error) {
return error.kind;
}
return std::nullopt;
}
Record pixels(QByteArray name, QRect bounds = QRect(0, 0, 2, 2))
{
Record record;
record.bounds = bounds;
record.name = name;
PSDReaderFixtures::solidChannels(bounds, record.channels);
return record;
}
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]);
}
}
class PSDReaderLayerTests : public QObject {
Q_OBJECT
private slots:
void everyCutBeforeTheLastLayerByteIsTruncated();
void aRecordWithoutItsSignatureIsTruncated();
void masksAndLayersSpendOneBudget();
void resourcesEndWhereTheyDeclare();
void packBitsRowsResumeAtTheirDeclaredEnd();
void vectorLayersAreReadWholeAndSpendTheBudget();
void textWithLevelsReadsAsAnAdjustment();
void aMissingFileIsUnreadableAndAnEmptyOneTruncated();
void blendKeysTrimAndAssetsKeepTheirIdentity();
};
void PSDReaderLayerTests::everyCutBeforeTheLastLayerByteIsTruncated()
{
PSDRecord group = PSDFixture::record("Folder");
group.isGroup = true;
PSDRecord inside = PSDFixture::record("Inside", PSDFixture::colorImage(3, 2, 1, 0, 0), QRectF(1, 1, 3, 2));
inside.parentID = group.id;
inside.mask = PSDFixture::grayImage(3, 2, 200);
const QByteArray data = PSDFixture::data(PSDDocument{8, 8, 72, {group, inside}}, PSDFixture::colorImage(8, 8, 0, 0, 0));
QCOMPARE(PSDReader::read(data).layers.size(), size_t(2));
// The header, the resources, the section's length, its info's length.
const qsizetype resources = u32(data, 30);
const qsizetype info = 26 + 4 + 4 + resources + 4;
const qsizetype last = info + 4 + qsizetype(u32(data, info)) - 1;
for (qsizetype cut = 0; cut < last; ++cut)
QCOMPARE(refusal<PSDError>([&] { PSDReader::read(data.left(cut)); }), std::optional(PSDError::Kind::truncated));
}
void PSDReaderLayerTests::aRecordWithoutItsSignatureIsTruncated()
{
Record record = pixels("Unsigned");
record.signature = "8BIX";
QCOMPARE(refusal<PSDError>([&] { PSDReader::read(PSDReaderFixtures::file({record})); }), std::optional(PSDError::Kind::truncated));
}
void PSDReaderLayerTests::masksAndLayersSpendOneBudget()
{
const QByteArray pair = PSDReaderFixtures::file({pixels("One"), pixels("Two")});
QCOMPARE(PSDReader::read(pair, 8).layers.size(), size_t(2));
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(pair, 7); }), std::optional(ImageImportError::Kind::tooLarge));
Record masked = pixels("Masked", QRect(0, 0, 1, 1));
masked.mask = QRect(0, 0, 2, 2);
masked.channels.push_back({-2, QByteArray("\0\0\x10\x20\x30\x40", 6)});
QCOMPARE(PSDReader::read(PSDReaderFixtures::file({masked}), 4).layers[0].mask.value().size(), QSize(2, 2));
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(PSDReaderFixtures::file({masked}), 3); }), std::optional(ImageImportError::Kind::tooLarge));
masked.mask = QRect(0, 0, 30'001, 1);
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(PSDReaderFixtures::file({masked})); }), std::optional(ImageImportError::Kind::tooLarge));
}
void PSDReaderLayerTests::resourcesEndWhereTheyDeclare()
{
PSDFixture::Buffer fixed;
fixed.string("8BIM");
fixed.u16(1005);
fixed.u16(0);
fixed.u32(8);
fixed.u32(150 * 65536);
fixed.u32(0);
// Eight bytes, too few for a resource, end the loop.
const PSDDocument document = PSDReader::read(PSDReaderFixtures::file({pixels("After")}, fixed.data + "junk1234"));
QCOMPARE(document.resolution, 150.0);
QCOMPARE(document.layers.at(0).name, QString("After"));
}
void PSDReaderLayerTests::packBitsRowsResumeAtTheirDeclaredEnd()
{
// Row 0 holds two unread bytes; row 1 follows them.
const QByteArray rows("\0\x04\0\x02\xff\x07\x55\x55\xff\x09", 10);
QCOMPARE(PSDChannelCoder::decode(1, 2, 2, rows), (std::vector<uchar>{7, 7, 9, 9}));
}
void PSDReaderLayerTests::vectorLayersAreReadWholeAndSpendTheBudget()
{
using namespace PSDVectorBuilders;
Record ellipse = pixels("Ellipse");
ellipse.extras = {{"8BIM", "SoCo", colorDescriptor(0, 110, 255)}, {"8BIM", "vogk", originationData(5, QRectF(10, 20, 30, 40))}};
Record circle;
circle.name = "Circle";
for (const auto &[key, payload] : PSDVectorFixtures::circle())
circle.extras.push_back({"8BIM", key == "vmsk" ? "vmsk" : key == "SoCo" ? "SoCo" : "vstk", payload});
const QByteArray data = PSDReaderFixtures::file({ellipse, circle}, QByteArray(), 1920, 1080);
const PSDDocument document = PSDReader::read(data);
// A live shape takes its own pixels over the layer's.
const PSDRecord &shape = document.layers[0];
QVERIFY(shape.kind == PSDLayerKind::vector && shape.shape.value().kind == ShapeKind::ellipse);
QCOMPARE(shape.bounds, QRectF(10, 20, 30, 40));
QCOMPARE(shape.image.value().size(), QSize(30, 40));
QVERIFY(shape.shapeNotes.empty());
const PSDRecord &raster = document.layers[1];
QVERIFY(raster.kind == PSDLayerKind::vector && !raster.shape);
QVERIFY(std::abs(raster.bounds.width() - 328) < 12);
QCOMPARE(raster.image.value().size(), raster.bounds.size().toSize());
// The channels, the shape and the raster share one budget.
const qint64 spent = 4 + 30 * 40 + qint64(raster.image.value().width()) * raster.image.value().height();
QCOMPARE(PSDReader::read(data, spent).layers.size(), size_t(2));
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(data, spent - 1); }), std::optional(ImageImportError::Kind::tooLarge));
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(data, 4 + 30 * 40 - 1); }), std::optional(ImageImportError::Kind::tooLarge));
// Text that draws a shape reads as one, stroke noted.
Record styled = ellipse;
styled.extras.push_back({"8BIM", "TySh", QByteArray("t")});
styled.extras.push_back({"8BIM", "vstk", strokeStyle(true, true, 3, 255, 0, 0)});
Record outlined = circle;
outlined.extras.push_back({"8BIM", "TySh", QByteArray("t")});
const QByteArray texts = PSDReaderFixtures::file({styled, outlined, circle}, QByteArray(), 1920, 1080);
const PSDDocument typed = PSDReader::read(texts);
QVERIFY(typed.layers[0].kind == PSDLayerKind::vector && typed.layers[1].kind == PSDLayerKind::vector);
QCOMPARE(typed.layers[0].shapeNotes, std::vector<QString>{"The Photoshop stroke isn’t supported on shape layers and was omitted."});
// A second raster spends what the first left.
const qint64 each = qint64(raster.image.value().width()) * raster.image.value().height();
QCOMPARE(PSDReader::read(texts, 4 + 30 * 40 + 2 * each).layers.size(), size_t(3));
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(texts, 4 + 30 * 40 + 2 * each - 1); }), std::optional(ImageImportError::Kind::tooLarge));
}
void PSDReaderLayerTests::textWithLevelsReadsAsAnAdjustment()
{
Record text = pixels("Words");
text.extras = {{"8BIM", "TySh", QByteArray("t")}, {"8BIM", "levl", QByteArray(292, '\0')}};
const PSDRecord record = PSDReader::read(PSDReaderFixtures::file({text})).layers.at(0);
QVERIFY(record.kind == PSDLayerKind::adjustment && record.adjustment.has_value());
}
void PSDReaderLayerTests::aMissingFileIsUnreadableAndAnEmptyOneTruncated()
{
const QTemporaryDir folder;
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(folder.filePath("missing.psd")); }), std::optional(ImageImportError::Kind::unreadable));
QFile empty(folder.filePath("empty.psd"));
QVERIFY(empty.open(QIODevice::WriteOnly));
empty.close();
QCOMPARE(refusal<PSDError>([&] { PSDReader::read(empty.fileName()); }), std::optional(PSDError::Kind::truncated));
QFile written(folder.filePath("one.psd"));
QVERIFY(written.open(QIODevice::WriteOnly) && written.write(PSDReaderFixtures::file({pixels("From disk")})) > 0);
written.close();
QCOMPARE(PSDReader::read(written.fileName()).layers.at(0).name, QString("From disk"));
// A pipe cannot be mapped: its bytes are read instead.
const QString pipe = folder.filePath("pipe.psd");
QCOMPARE(mkfifo(QFile::encodeName(pipe).constData(), 0600), 0);
const QByteArray bytes = PSDReaderFixtures::file({pixels("Through a pipe")});
std::thread writer([&] {
QFile end(pipe);
if (end.open(QIODevice::WriteOnly))
end.write(bytes);
});
const PSDDocument piped = PSDReader::read(pipe);
writer.join();
QCOMPARE(piped.layers.at(0).name, QString("Through a pipe"));
}
void PSDReaderLayerTests::blendKeysTrimAndAssetsKeepTheirIdentity()
{
Record spaced = pixels("Spaced");
spaced.blendKey = " xy ";
Record tabbed = pixels("Tabbed");
tabbed.blendKey = "\txy\t";
const PSDDocument document = PSDReader::read(PSDReaderFixtures::file({spaced, tabbed}));
const PSDImport imported = PSDDocumentBuilder::makeImport(document);
QCOMPARE(imported.conversions.at(0).message, QString("Blend mode “xy” isn’t supported and will be applied as Normal."));
QCOMPARE(imported.conversions.at(1).message, QString("Blend mode “xy” isn’t supported and will be applied as Normal."));
// The worker's assets are the layers' own, named for them.
const std::map<QUuid, ImportedImage> assets = PSDDocumentBuilder::assets(document);
QCOMPARE(assets.size(), size_t(2));
QCOMPARE(assets.at(document.layers[1].id).name, QString("Tabbed"));
const PSDImport given = PSDDocumentBuilder::makeImport(document, assets);
QCOMPARE(given.layers[1].asset.value().identity(), assets.at(document.layers[1].id).identity());
QCOMPARE(given.layers[1].name, QString("Tabbed"));
// A folder clipped to the layer below is never linked.
Record clipped;
clipped.name = "Clipped folder";
clipped.clipping = true;
clipped.extras.push_back({"8BIM", "lsct", PSDReaderFixtures::section(1)});
Record divider;
divider.extras.push_back({"8BIM", "lsct", PSDReaderFixtures::section(3)});
const PSDImport folders = PSDDocumentBuilder::makeImport(PSDReader::read(PSDReaderFixtures::file({pixels("Base"), divider, clipped})));
QVERIFY(folders.layers.at(1).isGroup && !folders.layers.at(1).maskSourceID);
QCOMPARE(folders.conversions.size(), size_t(1));
QCOMPARE(folders.conversions[0].layerName + folders.conversions[0].message,
QString("Clipped folderThis clipping mask’s base isn’t supported, so clipping was skipped."));
}
QTEST_GUILESS_MAIN(PSDReaderLayerTests)
#include "PSDReaderLayerTests.moc"
+369
View File
@@ -0,0 +1,369 @@
#include "IO/PSD/PSDChannelCoder.h"
#include "IO/PSD/PSDDocumentBuilder.h"
#include "IO/PSD/PSDReader.h"
#include "PSDReaderFixtures.h"
#include <QColorSpace>
#include <QtTest>
// The reader's edges Swift's tests leave: bytes, names, flags, kinds.
using PSDReaderFixtures::Record;
namespace {
template <typename Error> std::optional<typename Error::Kind> refusal(const std::function<void()> &run)
{
try {
run();
} catch (const Error &error) {
return error.kind;
}
return std::nullopt;
}
Record pixels(QByteArray name = "Layer", QRect bounds = QRect(0, 0, 2, 2))
{
Record record;
record.bounds = bounds;
record.name = name;
PSDReaderFixtures::solidChannels(bounds, record.channels);
return record;
}
Record folder(quint32 type, QByteArray name = "Folder", const char *blend = "norm")
{
Record record;
record.name = name;
record.blendKey = blend;
record.extras.push_back({"8BIM", "lsct", PSDReaderFixtures::section(type)});
return record;
}
QByteArray resource(quint16 id, const QByteArray &name, const QByteArray &body)
{
PSDFixture::Buffer data;
data.string("8BIM");
data.u16(id);
data.u8(uchar(name.size()));
data.bytes(name);
if ((name.size() + 1) % 2 == 1)
data.u8(0);
data.u32(quint32(body.size()));
data.bytes(body);
if (body.size() % 2 == 1)
data.u8(0);
return data.data;
}
QByteArray resolution(double dpi)
{
PSDFixture::Buffer fixed;
fixed.u32(quint32(dpi * 65536));
fixed.u32(0);
return resource(1005, QByteArray(), fixed.data);
}
std::vector<QString> messages(const PSDImport &imported)
{
std::vector<QString> result;
for (const PSDConversion &conversion : imported.conversions)
result.push_back(conversion.layerName + ": " + conversion.message);
return result;
}
}
class PSDReaderTests : public QObject {
Q_OBJECT
private slots:
void packBitsDecodesRunsLiteralsAndNoOps();
void planesPremultiplyAndMasksCopy();
void namesReadMacRomanAndPreferUnicode();
void fillMultipliesOpacityUnlessEffectsKeepIt();
void maskFlagsAndMasksFromRenderedPixels();
void longBlocksReadAndRefuseWildLengths();
void resolutionComesFromItsResource();
void layerCountsAndSections();
void foldersPairWithTheirDividers();
void kindsReportWhatTheImportConverts();
void clippingNeedsAPixelBaseBelow();
void levelsCurvesAndHueParse();
};
void PSDReaderTests::packBitsDecodesRunsLiteralsAndNoOps()
{
// Row 0: literal pair, run; row 1: no-op, run.
const QByteArray rows("\0\x05\0\x03\x01\x0a\x14\xff\x1e\x80\xfd\x07", 12);
QCOMPARE(PSDChannelCoder::decode(1, 4, 2, rows), (std::vector<uchar>{10, 20, 30, 30, 7, 7, 7, 7}));
const auto truncated = [](const QByteArray &data) {
return refusal<PSDError>([&] { PSDChannelCoder::decode(1, 4, 1, data); });
};
const std::optional kind(PSDError::Kind::truncated);
QCOMPARE(truncated(QByteArray("\0", 1)), kind);
QCOMPARE(truncated(QByteArray("\0\x09\x01\x0a", 4)), kind);
QCOMPARE(truncated(QByteArray("\0\x06\x04\1\2\3\4\5", 8)), kind);
QCOMPARE(truncated(QByteArray("\0\x01\xfd", 3)), kind);
QCOMPARE(truncated(QByteArray("\0\x02\xff\x07", 4)), kind);
QCOMPARE(PSDChannelCoder::decode(0, 2, 2, QByteArray("abcde")), (std::vector<uchar>{'a', 'b', 'c', 'd'}));
QCOMPARE(refusal<PSDError>([] { PSDChannelCoder::decode(0, 2, 2, QByteArray("abc")); }), kind);
QCOMPARE(refusal<PSDError>([] { PSDChannelCoder::decode(2, 2, 2, QByteArray("abcd")); }), std::optional(PSDError::Kind::unsupportedCompression));
QVERIFY(PSDChannelCoder::decode(2, 0, 2, QByteArray()).empty());
}
void PSDReaderTests::planesPremultiplyAndMasksCopy()
{
const QImage image = PSDChannelCoder::rgbaImage(1, 1, {200}, {255}, {0}, {128});
QCOMPARE(image.format(), QImage::Format_RGBA8888_Premultiplied);
QVERIFY(image.colorSpace() == QColorSpace(QColorSpace::SRgb));
const uchar *pixel = image.constScanLine(0);
QCOMPARE((std::vector<uchar>(pixel, pixel + 4)), (std::vector<uchar>{100, 128, 0, 128}));
const QImage mask = PSDChannelCoder::maskImage(3, 2, {1, 2, 3, 4, 5, 6});
QCOMPARE(mask.format(), QImage::Format_Grayscale8);
QCOMPARE(mask.constScanLine(1)[2], uchar(6));
// A missing plane reads black, or opaque for alpha.
Record colourless;
colourless.bounds = QRect(0, 0, 1, 1);
colourless.channels = {{0, QByteArray("\0\0\xff", 3)}};
const QImage red = PSDReader::read(PSDReaderFixtures::file({colourless})).layers.at(0).image.value();
QCOMPARE(red.pixel(0, 0), qRgba(255, 0, 0, 255));
}
void PSDReaderTests::namesReadMacRomanAndPreferUnicode()
{
Record roman = pixels(QByteArray("Caf\x8e \xa5", 6));
Record unicode = pixels("ignored");
unicode.extras.push_back({"8BIM", "luni", PSDReaderFixtures::unicode(QString::fromUtf8("\0Übung\0\0", 9))});
Record empty = pixels("");
Record noUnits = pixels("Kept");
noUnits.extras.push_back({"8BIM", "luni", QByteArray(4, '\0')});
const PSDDocument document = PSDReader::read(PSDReaderFixtures::file({roman, unicode, empty, noUnits}));
QCOMPARE(document.layers[0].name, QString::fromUtf8("Café •"));
QCOMPARE(document.layers[1].name, QString::fromUtf8("Übung"));
QCOMPARE(document.layers[2].name, QString("Layer"));
QCOMPARE(document.layers[3].name, QString("Kept"));
}
void PSDReaderTests::fillMultipliesOpacityUnlessEffectsKeepIt()
{
Record filled = pixels();
filled.opacity = 128;
filled.extras.push_back({"8BIM", "iOpa", QByteArray("\x80", 1)});
Record effects = pixels();
effects.extras = {{"8BIM", "iOpa", QByteArray("\x80", 1)}, {"8BIM", "lfx2", QByteArray("fx")}};
Record full = pixels();
full.extras = {{"8BIM", "iOpa", QByteArray("\xff", 1)}, {"8BIM", "lrFX", QByteArray("fx")}};
full.opacity = 51;
const PSDDocument document = PSDReader::read(PSDReaderFixtures::file({filled, effects, full}));
QCOMPARE(document.layers[0].opacity, (128 / 255.0) * (128 / 255.0));
QCOMPARE(document.layers[1].opacity, 1.0);
QVERIFY(document.layers[1].kind == PSDLayerKind::effects);
QCOMPARE(document.layers[2].opacity, 0.2);
QCOMPARE(messages(PSDDocumentBuilder::makeImport(document)),
(std::vector<QString>{"Layer: Layer effects were discarded, so the appearance may differ.",
"Layer: Layer effects were discarded, so the appearance may differ."}));
}
void PSDReaderTests::maskFlagsAndMasksFromRenderedPixels()
{
std::vector<Record> records;
for (const uchar flags : {0, 1, 2, 8}) {
Record record = pixels();
record.mask = QRect(1, 1, 3, 1);
record.maskFlags = flags;
record.channels.push_back({-2, QByteArray("\0\0\x10\x20\x30", 5)});
records.push_back(record);
}
const PSDDocument document = PSDReader::read(PSDReaderFixtures::file(records));
const QImage mask = document.layers[0].mask.value();
QCOMPARE(mask.size(), QSize(3, 1));
QCOMPARE((std::vector<uchar>(mask.constScanLine(0), mask.constScanLine(0) + 3)), (std::vector<uchar>{0x10, 0x20, 0x30}));
QVERIFY(document.layers[0].maskEnabled && document.layers[0].maskLinked);
QVERIFY(document.layers[1].maskEnabled && !document.layers[1].maskLinked);
QVERIFY(!document.layers[2].maskEnabled && document.layers[2].maskLinked);
QVERIFY(!document.layers[3].mask);
const PSDImport imported = PSDDocumentBuilder::makeImport(document);
QVERIFY(imported.layers[1].mask.value().isEnabled && !imported.layers[1].mask.value().isLinked);
QVERIFY(!imported.layers[2].mask.value().isEnabled);
// A mask that is not gray is reported, left off.
PSDDocument coloured = document;
coloured.layers[0].mask = PSDFixture::colorImage(3, 1, 1, 1, 1);
const PSDImport skipped = PSDDocumentBuilder::makeImport(coloured);
QVERIFY(!skipped.layers[0].mask);
QCOMPARE(messages(skipped), std::vector<QString>{"Layer: The layer mask couldn’t be converted to 8-bit grayscale and was skipped."});
}
void PSDReaderTests::longBlocksReadAndRefuseWildLengths()
{
Record wide = pixels("pascal");
wide.extras.push_back({"8B64", "luni", PSDReaderFixtures::unicode("Long")});
QCOMPARE(PSDReader::read(PSDReaderFixtures::file({wide})).layers[0].name, QString("Long"));
wide.extras[0].high = 0x80000000;
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(PSDReaderFixtures::file({wide})); }), std::optional(ImageImportError::Kind::tooLarge));
wide.extras[0].high = 1;
QCOMPARE(refusal<PSDError>([&] { PSDReader::read(PSDReaderFixtures::file({wide})); }), std::optional(PSDError::Kind::truncated));
// A block of another signature ends the extras.
Record odd = pixels("pascal");
odd.extras = {{"XXXX", "luni", PSDReaderFixtures::unicode("Never")}};
QCOMPARE(PSDReader::read(PSDReaderFixtures::file({odd})).layers[0].name, QString("pascal"));
}
void PSDReaderTests::resolutionComesFromItsResource()
{
const auto dpi = [](const QByteArray &resources) { return PSDReader::read(PSDReaderFixtures::file({pixels()}, resources)).resolution; };
QCOMPARE(dpi(QByteArray()), 72.0);
QCOMPARE(dpi(resolution(300)), 300.0);
QCOMPARE(dpi(resolution(0.5)), 72.0);
QCOMPARE(dpi(resolution(20'000)), 9600.0);
// Odd names and bodies are padded before the next resource.
QCOMPARE(dpi(resource(1000, "ab", "xyz") + resolution(150)), 150.0);
QCOMPARE(dpi(resource(1005, QByteArray(), "abc") + resolution(150)), 150.0);
}
void PSDReaderTests::layerCountsAndSections()
{
const QByteArray noLayers = PSDReaderFixtures::header(8, 8) + QByteArray(8, '\0');
const PSDDocument empty = PSDReader::read(noLayers);
QVERIFY(empty.layers.empty());
QCOMPARE(empty.width, 8);
// A negative count reads as its size.
QCOMPARE(PSDReader::read(PSDReaderFixtures::file({pixels()}, QByteArray(), 8, 8, -1)).layers.size(), size_t(1));
QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(PSDReaderFixtures::file({}, QByteArray(), 8, 8, 10'001)); }),
std::optional(ImageImportError::Kind::tooLarge));
QCOMPARE(refusal<PSDError>([] { PSDReader::read(PSDReaderFixtures::file({}, QByteArray(), 8, 8, 10'000)); }), std::optional(PSDError::Kind::truncated));
QCOMPARE(refusal<PSDError>([] { PSDReader::read(PSDReaderFixtures::header(8, 8)); }), std::optional(PSDError::Kind::truncated));
}
void PSDReaderTests::foldersPairWithTheirDividers()
{
Record closed = folder(2, "Closed");
closed.extras[0].key = "lsdk";
const PSDDocument document = PSDReader::read(PSDReaderFixtures::file({folder(3), pixels("Inside"), closed, folder(1, "Lone", "mul ")}));
QCOMPARE(document.layers.size(), size_t(3));
QVERIFY(document.layers[1].isGroup && document.layers[1].blendKey == "pass");
QCOMPARE(document.layers[0].parentID, std::optional(document.layers[1].id));
QCOMPARE(document.layers[1].bounds, QRectF(0, 0, 8, 8));
QVERIFY(!document.layers[1].image);
QVERIFY(document.layers[2].isGroup && !document.layers[2].parentID);
QCOMPARE(document.layers[2].blendKey, QString("mul "));
QCOMPARE(messages(PSDDocumentBuilder::makeImport(document)),
std::vector<QString>{"Lone: Folder blend mode “mul ” isn’t supported. The folder will be pass-through."});
QCOMPARE(refusal<PSDError>([] { PSDReader::read(PSDReaderFixtures::file({folder(3), pixels()})); }), std::optional(PSDError::Kind::truncated));
}
void PSDReaderTests::kindsReportWhatTheImportConverts()
{
Record text = pixels("Words");
text.extras.push_back({"8BIM", "TySh", QByteArray("t")});
Record smart = pixels("Smart");
smart.extras.push_back({"8BIM", "SoLd", QByteArray("s")});
Record outline = pixels("Outline");
outline.extras.push_back({"8BIM", "vsms", QByteArray("v")});
Record exposure;
exposure.name = "Exposure";
exposure.extras.push_back({"8BIM", "expA", QByteArray("e")});
Record weird = pixels("Weird");
weird.blendKey = "\xff\xff\xff\xff";
const PSDDocument document = PSDReader::read(PSDReaderFixtures::file({text, smart, outline, exposure, weird}));
QVERIFY(document.layers[0].kind == PSDLayerKind::text);
QVERIFY(document.layers[1].kind == PSDLayerKind::smartObject);
QVERIFY(document.layers[2].kind == PSDLayerKind::vector);
QVERIFY(document.layers[3].kind == PSDLayerKind::adjustment && !document.layers[3].adjustment);
// A key that is no ASCII reads as nothing.
QCOMPARE(document.layers[4].blendKey, QString());
const PSDImport imported = PSDDocumentBuilder::makeImport(document);
QCOMPARE(messages(imported), (std::vector<QString>{
"Words: Editable Photoshop text becomes pixels and can’t be retyped.",
"Smart: The smart object was rasterized. Linked contents can’t be edited.",
"Outline: Vector shape was rasterized to pixels.",
"Exposure: This adjustment type isn’t supported and was skipped.",
"Weird: Blend mode “” isn’t supported and will be applied as Normal.",
}));
QCOMPARE(imported.layers.size(), size_t(4));
QCOMPARE(imported.layers[3].name, QString("Weird"));
}
void PSDReaderTests::clippingNeedsAPixelBaseBelow()
{
Record bottom = pixels("Bottom");
bottom.clipping = true;
Record under = pixels("Under");
Record levels;
levels.name = "Levels";
levels.extras.push_back({"8BIM", "levl", QByteArray(292, '\0')});
Record onLevels = pixels("On Levels");
onLevels.clipping = true;
Record base = pixels("Base");
Record first = pixels("First");
first.clipping = true;
Record second = pixels("Second");
second.clipping = true;
// An adjustment between parts clipping from the base below.
const PSDDocument document = PSDReader::read(PSDReaderFixtures::file({bottom, under, levels, onLevels, base, first, second}));
const PSDImport imported = PSDDocumentBuilder::makeImport(document);
QVERIFY(imported.layers[2].adjustment.has_value());
QVERIFY(!imported.layers[0].maskSourceID && !imported.layers[3].maskSourceID);
QCOMPARE(imported.layers[5].maskSourceID, std::optional(imported.layers[4].id));
QCOMPARE(imported.layers[6].maskSourceID, std::optional(imported.layers[4].id));
const QString skipped = QStringLiteral(": This clipping mask’s base isn’t supported, so clipping was skipped.");
QCOMPARE(messages(imported), (std::vector<QString>{"Levels: Adjustment parameters may not match Photoshop exactly.", "Bottom" + skipped,
"On Levels" + skipped}));
}
void PSDReaderTests::levelsCurvesAndHueParse()
{
PSDFixture::Buffer levels;
levels.u16(2);
for (int channel = 0; channel < 4; ++channel) {
for (const quint16 value : {10, 240, 5, 250, 512})
levels.u16(quint16(value + channel));
}
levels.bytes(QByteArray(292 - levels.data.size(), '\0'));
const LayerAdjustment level = PSDAdjustments::parse({{"levl", levels.data}}).value();
QVERIFY(level.kind == AdjustmentKind::levels);
QCOMPARE(level.levels.ranges[0], (LevelRange{10, 2, 240, 5, 250}));
QCOMPARE(level.levels.ranges[3], (LevelRange{13, 515 / 256.0, 243, 8, 253}));
QVERIFY(!PSDAdjustments::parse({{"levl", levels.data.left(291)}}));
PSDFixture::Buffer curves;
curves.u8(0);
curves.u16(1);
curves.u16(1);
curves.u16(2);
for (const quint16 value : {255, 200, 0, 50})
curves.u16(value);
const LayerAdjustment curve = PSDAdjustments::parse({{"curv", curves.data}}).value();
QVERIFY(curve.kind == AdjustmentKind::curves);
QCOMPARE(curve.curves.channels[0], (std::vector<CurvePoint>{{0, 0}, {50, 0}, {200, 255}, {255, 255}}));
QCOMPARE(curve.curves.channels[1], (std::vector<CurvePoint>{{0, 0}, {255, 255}}));
QByteArray version = curves.data;
version[2] = 2;
QVERIFY(!PSDAdjustments::parse({{"curv", version}}));
QVERIFY(!PSDAdjustments::parse({{"curv", curves.data.left(curves.data.size() - 1)}}));
QVERIFY(!PSDAdjustments::parse({{"curv", QByteArray("\0\0\x01\0", 4)}}));
PSDFixture::Buffer twice;
twice.u8(0);
twice.u16(4);
twice.u16(1);
twice.u16(2);
for (const quint16 value : {10, 50, 20, 50})
twice.u16(value);
QVERIFY(!PSDAdjustments::parse({{"curv", twice.data}}));
PSDFixture::Buffer hue;
hue.u16(0);
hue.u8(1);
hue.u8(0);
for (const qint16 value : {30, -20, 10, -5, 40, 0})
hue.i16(value);
const LayerAdjustment shift = PSDAdjustments::parse({{"hue ", hue.data}}).value();
QVERIFY(shift.kind == AdjustmentKind::hsv);
const HueSaturationSettings settings = shift.hsvSettings.value();
QVERIFY(settings.colorize);
QCOMPARE(settings.adjustments.at(ColorRange::master), (RangeAdjustment{30, -20, 10}));
QCOMPARE(settings.adjustments.at(ColorRange::reds), (RangeAdjustment{-5, 40, 0}));
QVERIFY(!settings.adjustments.contains(ColorRange::yellows));
// `hue2` comes before `hue `.
PSDFixture::Buffer plain;
plain.u32(0);
QVERIFY(!PSDAdjustments::parse({{"hue2", plain.data}, {"hue ", hue.data}}).value().hsvSettings.value().colorize);
QVERIFY(!PSDAdjustments::parse({{"hue ", QByteArray("\0\0\1", 3)}}));
QVERIFY(!PSDAdjustments::parse({{"expA", hue.data}}));
}
QTEST_GUILESS_MAIN(PSDReaderTests)
#include "PSDReaderTests.moc"
+297
View File
@@ -0,0 +1,297 @@
#include "IO/PSD/PSDDocumentBuilder.h"
#include "IO/PSD/PSDReader.h"
#include "PSDFixture.h"
#include <QTemporaryDir>
#include <QtTest>
// Swift's PSDRoundTripTests: files written by the fixture, read back.
namespace {
QByteArray header(quint16 version = 1, quint32 width = 8, quint32 height = 8, quint16 depth = 8, quint16 mode = 3)
{
PSDFixture::Buffer data;
data.string("8BPS");
data.u16(version);
data.bytes(QByteArray(6, '\0'));
data.u16(3);
data.u32(height);
data.u32(width);
data.u16(depth);
data.u16(mode);
return data.data;
}
QByteArray rawChannel(const QByteArray &plane)
{
return QByteArray("\0\0", 2) + plane;
}
// One layer "Big" at the origin with the given channels.
QByteArray layerFile(qint32 layerWidth, qint32 layerHeight, const std::vector<PSDFixture::Channel> &channels, quint32 canvasWidth = 8,
quint32 canvasHeight = 8)
{
PSDFixture::Buffer data;
data.bytes(header(1, canvasWidth, canvasHeight));
data.u32(0);
data.u32(0);
PSDFixture::Buffer records, payloads;
records.i16(1);
records.i32(0);
records.i32(0);
records.i32(layerHeight);
records.i32(layerWidth);
records.u16(quint16(channels.size()));
for (const PSDFixture::Channel &channel : channels) {
records.i16(channel.id);
records.u32(quint32(channel.payload.size()));
payloads.bytes(channel.payload);
}
records.string("8BIMnorm");
records.bytes(QByteArray("\xff\0\0\0", 4));
records.u32(12);
records.u32(0);
records.u32(0);
records.u8(3);
records.string("Big");
PSDFixture::Buffer info;
info.u32(quint32(records.data.size() + payloads.data.size()));
info.bytes(records.data);
info.bytes(payloads.data);
info.u32(0);
data.u32(quint32(info.data.size()));
data.bytes(info.data);
return data.data;
}
std::vector<quint32> lsctTypes(const QByteArray &data)
{
std::vector<quint32> types;
qsizetype search = 0;
for (qsizetype found = data.indexOf("lsct", search); found >= 0 && found + 12 <= data.size(); found = data.indexOf("lsct", search)) {
const qsizetype type = found + 8;
types.push_back(quint32(uchar(data[type])) << 24 | quint32(uchar(data[type + 1])) << 16 | quint32(uchar(data[type + 2])) << 8 | uchar(data[type + 3]));
search = found + 4;
}
return types;
}
ImageLayer named(const PSDImport &imported, const QString &name)
{
const auto found = std::find_if(imported.layers.begin(), imported.layers.end(), [&](const ImageLayer &layer) { return layer.name == name; });
if (found == imported.layers.end())
throw std::runtime_error("no layer named " + name.toStdString());
return *found;
}
template <typename Error> std::optional<typename Error::Kind> refusal(const std::function<void()> &run)
{
try {
run();
} catch (const Error &error) {
return error.kind;
}
return std::nullopt;
}
}
class PSDRoundTripTests : public QObject {
Q_OBJECT
private slots:
void roundTripLayersOrderVisibilityOpacityAndBlend();
void roundTripGroupsMasksAndClipping();
void importedGroupsFollowPhotoshopLsctOrder();
void oversizedLayerBoundsAreRejected();
void unusedSpotChannelsAreSkippedBeforeDecode();
void unsupportedCompressionOnColorChannelsIsStillRejected();
void matchesRequiresPhotoshopMagic();
void unknownBlendProducesConversionReport();
void softLightImportsWithoutConversion();
void folderOpacityImportsOntoTheFolder();
void unsupportedHeadersAreRejected();
};
void PSDRoundTripTests::roundTripLayersOrderVisibilityOpacityAndBlend()
{
PSDRecord bottom = PSDFixture::record("Red", PSDFixture::colorImage(2, 2, 1, 0, 0), QRectF(0, 0, 2, 2));
bottom.opacity = 0.5;
bottom.blendKey = "mul ";
PSDRecord top = PSDFixture::record("Blue", PSDFixture::colorImage(2, 2, 0, 0, 1), QRectF(2, 0, 2, 2));
top.isVisible = false;
const QByteArray data = PSDFixture::data(PSDDocument{4, 4, 144, {bottom, top}}, PSDFixture::colorImage(4, 4, 0, 0, 0, 0));
QVERIFY(data.startsWith("8BPS"));
const PSDDocument document = PSDReader::read(data);
QCOMPARE(document.width, 4);
QCOMPARE(document.height, 4);
QCOMPARE(document.resolution, 144.0);
QCOMPARE(document.layers.size(), size_t(2));
QCOMPARE(document.layers[0].name, QString("Red"));
QCOMPARE(document.layers[1].name, QString("Blue"));
QVERIFY(document.layers[0].isVisible && !document.layers[1].isVisible);
QVERIFY(std::abs(document.layers[0].opacity - 0.5) < 0.01);
QCOMPARE(document.layers[0].blendKey, QString("mul "));
QCOMPARE(document.layers[0].image.value().width(), 2);
// The pixels come back as written, premultiplied.
QCOMPARE(document.layers[0].image.value().pixel(1, 1), qRgba(255, 0, 0, 255));
QCOMPARE(document.layers[0].bounds, QRectF(0, 0, 2, 2));
QCOMPARE(document.layers[1].bounds, QRectF(2, 0, 2, 2));
const PSDImport imported = PSDDocumentBuilder::makeImport(document);
QVERIFY(imported.conversions.empty());
QCOMPARE(imported.layers.size(), size_t(2));
QCOMPARE(imported.layers[0].name, QString("Red"));
QCOMPARE(imported.layers[1].name, QString("Blue"));
QVERIFY(imported.layers[0].blendMode == LayerBlendMode::multiply);
QVERIFY(!imported.layers[1].isVisible);
QCOMPARE(imported.layers[1].origin(), QPointF(2, 0));
QCOMPARE(imported.layers[1].size(), QSizeF(2, 2));
QCOMPARE(imported.layers[0].id, document.layers[0].id);
QCOMPARE(imported.layers[0].asset.value().name, QString("Red"));
QVERIFY(!imported.layers[0].asset.value().thumbnail.isNull());
}
void PSDRoundTripTests::roundTripGroupsMasksAndClipping()
{
const QUuid groupID = QUuid::createUuid();
PSDRecord group = PSDFixture::record("Stack");
group.id = groupID;
group.isGroup = true;
group.blendKey = "pass";
PSDRecord base = PSDFixture::record("Base", PSDFixture::colorImage(2, 2, 0, 1, 0), QRectF(0, 0, 2, 2));
base.parentID = groupID;
base.mask = PSDFixture::grayImage(2, 2, 255);
PSDRecord child = PSDFixture::record("Clipped", PSDFixture::colorImage(2, 2, 1, 1, 0), QRectF(0, 0, 2, 2));
child.parentID = groupID;
child.clipping = true;
const QByteArray data = PSDFixture::data(PSDDocument{4, 4, 72, {group, base, child}}, PSDFixture::colorImage(4, 4, 0, 0, 0, 0));
const PSDImport imported = PSDDocumentBuilder::makeImport(PSDReader::read(data));
QVERIFY(imported.conversions.empty());
const ImageLayer folder = named(imported, "Stack");
QVERIFY(folder.isGroup);
QCOMPARE(folder.size(), QSizeF(4, 4));
const ImageLayer importedBase = named(imported, "Base"), importedChild = named(imported, "Clipped");
QCOMPARE(importedBase.parentID, std::optional(folder.id));
QCOMPARE(importedChild.parentID, std::optional(folder.id));
QVERIFY(importedBase.mask.has_value());
QCOMPARE(importedBase.mask.value().asset.image().pixel(0, 0), qRgb(255, 255, 255));
QCOMPARE(importedChild.maskSourceID, std::optional(importedBase.id));
}
void PSDRoundTripTests::importedGroupsFollowPhotoshopLsctOrder()
{
PSDRecord group = PSDFixture::record("Stack");
group.isGroup = true;
PSDRecord child = PSDFixture::record("Base", PSDFixture::colorImage(2, 2, 0, 1, 0), QRectF(0, 0, 2, 2));
child.parentID = group.id;
const QByteArray data = PSDFixture::data(PSDDocument{4, 4, 72, {group, child}}, PSDFixture::colorImage(4, 4, 0, 0, 0, 0));
QCOMPARE(lsctTypes(data), (std::vector<quint32>{3, 1}));
const PSDImport imported = PSDDocumentBuilder::makeImport(PSDReader::read(data));
const ImageLayer folder = named(imported, "Stack");
QVERIFY(folder.isGroup);
QCOMPARE(named(imported, "Base").parentID, std::optional(folder.id));
}
void PSDRoundTripTests::oversizedLayerBoundsAreRejected()
{
const QByteArray raw("\0\0", 2);
const auto oversized = layerFile(30'000, 30'000, {{-1, raw}, {0, raw}, {1, raw}, {2, raw}});
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(oversized); }), std::optional(ImageImportError::Kind::tooLarge));
const QImage fill = PSDFixture::colorImage(20, 20, 1, 0, 0);
const QByteArray data = PSDFixture::data(PSDDocument{20, 20, 72, {PSDFixture::record("Huge", fill, QRectF(0, 0, 20, 20))}}, fill);
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(data, 50); }), std::optional(ImageImportError::Kind::tooLarge));
// The budget holds at its edge: 400 read, 399 not.
QCOMPARE(PSDReader::read(data, 400).layers.size(), size_t(1));
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(data, 399); }), std::optional(ImageImportError::Kind::tooLarge));
}
void PSDRoundTripTests::unusedSpotChannelsAreSkippedBeforeDecode()
{
std::vector<PSDFixture::Channel> channels;
for (const qint16 id : {-1, 0, 1, 2})
channels.push_back({id, rawChannel(QByteArray(4, '\xff'))});
// Compression 99 throws if unpacked; 56 channels at most.
for (qint16 id = 3; id <= 54; ++id)
channels.push_back({id, QByteArray("\0\x63\0\0", 4)});
const PSDDocument document = PSDReader::read(layerFile(2, 2, channels));
QCOMPARE(document.layers.size(), size_t(1));
QCOMPARE(document.layers[0].image.value().size(), QSize(2, 2));
QCOMPARE(document.layers[0].name, QString("Big"));
channels.push_back({55, QByteArray("\0\0", 2)});
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(layerFile(2, 2, channels)); }), std::optional(ImageImportError::Kind::tooLarge));
}
void PSDRoundTripTests::unsupportedCompressionOnColorChannelsIsStillRejected()
{
const QByteArray pixels(4, '\xff');
const std::vector<PSDFixture::Channel> channels{{-1, rawChannel(pixels)}, {0, QByteArray("\0\x63\0\0", 4)}, {1, rawChannel(pixels)}, {2, rawChannel(pixels)}};
QCOMPARE(refusal<PSDError>([&] { PSDReader::read(layerFile(2, 2, channels)); }), std::optional(PSDError::Kind::unsupportedCompression));
}
void PSDRoundTripTests::matchesRequiresPhotoshopMagic()
{
const QTemporaryDir folder;
QFile jpeg(folder.filePath("picture.psd"));
QVERIFY(jpeg.open(QIODevice::WriteOnly) && jpeg.write("\xff\xd8\xff\xe0", 4) == 4);
jpeg.close();
QVERIFY(!PSDReader::matches(jpeg.fileName()));
QFile psd(folder.filePath("picture.bin"));
QVERIFY(psd.open(QIODevice::WriteOnly) && psd.write("8BPS") == 4);
psd.close();
QVERIFY(PSDReader::matches(psd.fileName()));
QVERIFY(!PSDReader::matches(folder.filePath("missing.psd")));
QVERIFY(PSDReader::matches(QByteArray("8BPS")) && !PSDReader::matches(QByteArray("8BP")));
}
void PSDRoundTripTests::unknownBlendProducesConversionReport()
{
const QImage fill = PSDFixture::colorImage(2, 2, 1, 0, 0);
PSDRecord layer = PSDFixture::record("Vivid", fill, QRectF(0, 0, 2, 2));
layer.blendKey = "vLit";
const PSDImport imported = PSDDocumentBuilder::makeImport(PSDReader::read(PSDFixture::data(PSDDocument{2, 2, 72, {layer}}, fill)));
QCOMPARE(imported.conversions.size(), size_t(1));
QCOMPARE(imported.conversions[0].layerName, QString("Vivid"));
QCOMPARE(imported.conversions[0].message, QString("Blend mode “vLit” isn’t supported and will be applied as Normal."));
QVERIFY(imported.layers[0].blendMode == LayerBlendMode::normal);
}
void PSDRoundTripTests::softLightImportsWithoutConversion()
{
const QImage fill = PSDFixture::colorImage(2, 2, 1, 0, 0);
PSDRecord layer = PSDFixture::record("Soft", fill, QRectF(0, 0, 2, 2));
layer.blendKey = "sLit";
const PSDImport imported = PSDDocumentBuilder::makeImport(PSDReader::read(PSDFixture::data(PSDDocument{2, 2, 72, {layer}}, fill)));
QVERIFY(imported.conversions.empty());
QVERIFY(imported.layers[0].blendMode == LayerBlendMode::softLight);
}
void PSDRoundTripTests::folderOpacityImportsOntoTheFolder()
{
const QImage fill = PSDFixture::colorImage(2, 2, 0, 1, 0);
PSDRecord group = PSDFixture::record("Stack");
group.isGroup = true;
group.opacity = 0.5;
PSDRecord child = PSDFixture::record("Base", fill, QRectF(0, 0, 2, 2));
child.parentID = group.id;
const PSDImport imported = PSDDocumentBuilder::makeImport(PSDReader::read(PSDFixture::data(PSDDocument{4, 4, 72, {group, child}}, fill)));
// One byte of opacity: half comes back as 128/255.
QCOMPARE(named(imported, "Stack").opacity, 128 / 255.0);
QVERIFY(imported.conversions.empty());
}
void PSDRoundTripTests::unsupportedHeadersAreRejected()
{
QCOMPARE(refusal<PSDError>([] { PSDReader::read(header(2)); }), std::optional(PSDError::Kind::unsupportedVersion));
QCOMPARE(refusal<PSDError>([] { PSDReader::read(header(1, 8, 8, 8, 4)); }), std::optional(PSDError::Kind::unsupportedColorMode));
QCOMPARE(refusal<PSDError>([] { PSDReader::read(header(1, 8, 8, 16)); }), std::optional(PSDError::Kind::unsupportedDepth));
QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(header(1, 30'001, 10)); }), std::optional(ImageImportError::Kind::tooLarge));
QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(header(1, 10'001, 10'000)); }), std::optional(ImageImportError::Kind::tooLarge));
QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(header(1, 0, 10)); }), std::optional(ImageImportError::Kind::tooLarge));
QCOMPARE(refusal<ImageImportError>([] { PSDReader::read(QByteArray("8BPX")); }), std::optional(ImageImportError::Kind::unreadable));
QCOMPARE(refusal<PSDError>([] { PSDReader::read(header()); }), std::optional(PSDError::Kind::truncated));
QCOMPARE(QString::fromUtf8(PSDError(PSDError::Kind::unsupportedDepth).what()), QString("Only 8-bit RGB Photoshop files can be imported."));
QCOMPARE(QString::fromUtf8(PSDError(PSDError::Kind::unsupportedColorMode).what()), QString("Only 8-bit RGB Photoshop files can be imported."));
QCOMPARE(QString::fromUtf8(PSDError(PSDError::Kind::unsupportedVersion).what()), QString("Large Document (.psb) Photoshop files aren’t supported."));
QCOMPARE(QString::fromUtf8(PSDError(PSDError::Kind::truncated).what()), QString("The Photoshop file could not be read. It may be damaged or incomplete."));
QCOMPARE(QString::fromUtf8(PSDError(PSDError::Kind::unsupportedCompression).what()),
QString("This Photoshop file uses a layer compression method that isn’t supported."));
}
QTEST_GUILESS_MAIN(PSDRoundTripTests)
#include "PSDRoundTripTests.moc"
+110
View File
@@ -0,0 +1,110 @@
#pragma once
#include "PSDFixture.h"
#include <bit>
// Photoshop's vector extras built byte by byte, as Swift's.
namespace PSDVectorBuilders {
inline void appendDouble(QByteArray &data, double value)
{
PSDFixture::Buffer bits;
const quint64 raw = std::bit_cast<quint64>(value);
bits.u32(quint32(raw >> 32));
bits.u32(quint32(raw));
data.append(bits.data);
}
inline void appendUnit(QByteArray &data, const char *key, double value)
{
data.append(key);
data.append("UntF#Pxl");
appendDouble(data, value);
}
inline QByteArray originationData(quint32 type, const QRectF &rect, const std::vector<double> &radii = {})
{
QByteArray data("keyOriginTypelong");
PSDFixture::Buffer kind;
kind.u32(type);
data.append(kind.data);
data.append("keyOriginShapeBBox");
appendUnit(data, "Left", rect.left());
appendUnit(data, "Top ", rect.top());
appendUnit(data, "Rght", rect.left() + rect.width());
appendUnit(data, "Btom", rect.top() + rect.height());
if (radii.size() == 4) {
data.append("keyOriginRRectRadii");
const char *keys[] = {"topLeft", "topRight", "bottomRight", "bottomLeft"};
for (size_t index = 0; index < 4; ++index)
appendUnit(data, keys[index], radii[index]);
}
return data;
}
struct Subpath {
std::vector<QPointF> corners;
bool closed = true;
// The knot count the record declares, else the corners'.
std::optional<int> count = std::nullopt;
// Handles this far either side of each anchor.
QPointF handle = QPointF();
};
// Subpaths of knots, in Photoshop's fractions of the canvas.
inline QByteArray vectorMask(QSizeF canvas, const std::vector<Subpath> &subpaths)
{
PSDFixture::Buffer data;
data.u32(3);
data.u32(0);
data.i16(6);
data.bytes(QByteArray(24, '\0'));
data.i16(8);
data.bytes(QByteArray(24, '\0'));
const auto point = [&](QPointF at) {
data.u32(quint32(qint32(at.y() / canvas.height() * 0x1000000)));
data.u32(quint32(qint32(at.x() / canvas.width() * 0x1000000)));
};
for (const Subpath &subpath : subpaths) {
data.i16(subpath.closed ? 0 : 3);
data.i16(qint16(subpath.count.value_or(int(subpath.corners.size()))));
data.bytes(QByteArray(22, '\0'));
for (const QPointF corner : subpath.corners) {
data.i16(1);
point(corner - subpath.handle);
point(corner);
point(corner + subpath.handle);
}
}
return data.data;
}
inline QByteArray vectorMask(QSizeF canvas, const std::vector<QPointF> &corners)
{
return vectorMask(canvas, {Subpath{corners}});
}
inline QByteArray colorDescriptor(double red, double green, double blue)
{
QByteArray data("RGBC");
for (const auto &[key, value] : {std::pair("Rd ", red), {"Grn ", green}, {"Bl ", blue}}) {
data.append(key);
data.append("doub");
appendDouble(data, value);
}
return data;
}
inline QByteArray strokeStyle(bool fill, bool stroke, double width, double red, double green, double blue)
{
QByteArray data;
for (const auto &[key, value] : {std::pair("strokeEnabled", stroke), {"fillEnabled", fill}}) {
data.append(key);
data.append("bool");
data.append(char(value ? 1 : 0));
}
data.append("strokeStyleLineWidthUntF#Pxl");
appendDouble(data, width);
data.append(colorDescriptor(red, green, blue));
return data;
}
}
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include <QByteArray>
#include <QSizeF>
#include <map>
#include <QString>
// Photoshop shape extras, as Swift's PSDVectorFixtures holds them.
namespace PSDVectorFixtures {
inline const QSizeF canvas(1920, 1080);
inline std::map<QString, QByteArray> circle()
{
return {
{QStringLiteral("vmsk"), QByteArray::fromBase64(
"AAAAAwAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAABAAEAAAAAAAAAAAAAAAAAAA"
"AAAAAAAQDHWSAAXn4FAMdZIABSZmYAx1kgAEZOxwABALX4sAA8iIgAoHlcADyIiACK+gkAPIiIAAEAeZmZAEZOxwB5mZkAUmZmAHmZmQBefgUA"
"AQCK+gkAaEREAKB5XABoREQAtfiwAGhERAAA")},
{QStringLiteral("SoCo"), QByteArray::fromBase64(
"AAAAEAAAAAEAAAAAAABudWxsAAAAAQAAAABDbHIgT2JqYwAAAAEAAAAAAABSR0JDAAAAAwAAAABSZCAgZG91YgAAAAAAAAAAAAAAAEdybiBkb3"
"ViQFuAAAAAAAAAAAAAQmwgIGRvdWJAb+AAAAAAAA==")},
{QStringLiteral("vstk"), QByteArray::fromBase64(
"AAAAEAAAAAEAAAAAAAtzdHJva2VTdHlsZQAAABAAAAASc3Ryb2tlU3R5bGVWZXJzaW9ubG9uZwAAAAIAAAANc3Ryb2tlRW5hYmxlZGJvb2wAAA"
"AAC2ZpbGxFbmFibGVkYm9vbAEAAAAUc3Ryb2tlU3R5bGVMaW5lV2lkdGhVbnRGI1B4bEAjvQfpGNNeAAAAGXN0cm9rZVN0eWxlTGluZURhc2hP"
"ZmZzZXRVbnRGI1BudAAAAAAAAAAAAAAAFXN0cm9rZVN0eWxlTWl0ZXJMaW1pdGRvdWJAWQAAAAAAAAAAABZzdHJva2VTdHlsZUxpbmVDYXBUeX"
"BlZW51bQAAABZzdHJva2VTdHlsZUxpbmVDYXBUeXBlAAAAEnN0cm9rZVN0eWxlQnV0dENhcAAAABdzdHJva2VTdHlsZUxpbmVKb2luVHlwZWVu"
"dW0AAAAXc3Ryb2tlU3R5bGVMaW5lSm9pblR5cGUAAAAUc3Ryb2tlU3R5bGVNaXRlckpvaW4AAAAYc3Ryb2tlU3R5bGVMaW5lQWxpZ25tZW50ZW"
"51bQAAABhzdHJva2VTdHlsZUxpbmVBbGlnbm1lbnQAAAAWc3Ryb2tlU3R5bGVBbGlnbkNlbnRlcgAAABRzdHJva2VTdHlsZVNjYWxlTG9ja2Jv"
"b2wAAAAAF3N0cm9rZVN0eWxlU3Ryb2tlQWRqdXN0Ym9vbAAAAAAWc3Ryb2tlU3R5bGVMaW5lRGFzaFNldFZsTHMAAAAAAAAAFHN0cm9rZVN0eW"
"xlQmxlbmRNb2RlZW51bQAAAABCbG5NAAAAAE5ybWwAAAASc3Ryb2tlU3R5bGVPcGFjaXR5VW50RiNQcmNAWQAAAAAAAAAAABJzdHJva2VTdHls"
"ZUNvbnRlbnRPYmpjAAAAAQAAAAAAD3NvbGlkQ29sb3JMYXllcgAAAAEAAAAAQ2xyIE9iamMAAAABAAAAAAAAUkdCQwAAAAMAAAAAUmQgIGRvdW"
"JAb+AAAAAAAAAAAABHcm4gZG91YkBv4AAAAAAAAAAAAEJsICBkb3ViAAAAAAAAAAAAAAAVc3Ryb2tlU3R5bGVSZXNvbHV0aW9uZG91YkBSAAAA"
"AAAA")},
};
}
inline std::map<QString, QByteArray> rectangle()
{
return {
{QStringLiteral("vmsk"), QByteArray::fromBase64(
"AAAAAwAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAABAAEAAAAAAAAAAAAAAAAAAA"
"AAAAAAAQAkREQA1CIiACRERADUIiIAJEREANQiIgABAE9oSwDUIiIAT2hLANQiIgBPaEsA1CIiAAEAT2hLAH4AAABPaEsAfgAAAE9oSwB+AAAA"
"AQAkREQAfgAAACRERAB+AAAAJEREAH4AAAAA")},
{QStringLiteral("SoCo"), QByteArray::fromBase64(
"AAAAEAAAAAEAAAAAAABudWxsAAAAAQAAAABDbHIgT2JqYwAAAAEAAAAAAABSR0JDAAAAAwAAAABSZCAgZG91YgAAAAAAAAAAAAAAAEdybiBkb3"
"ViAAAAAAAAAAAAAAAAQmwgIGRvdWIAAAAAAAAAAA==")},
{QStringLiteral("vstk"), QByteArray::fromBase64(
"AAAAEAAAAAEAAAAAAAtzdHJva2VTdHlsZQAAABAAAAASc3Ryb2tlU3R5bGVWZXJzaW9ubG9uZwAAAAIAAAANc3Ryb2tlRW5hYmxlZGJvb2wBAA"
"AAC2ZpbGxFbmFibGVkYm9vbAEAAAAUc3Ryb2tlU3R5bGVMaW5lV2lkdGhVbnRGI1B4bEAjvQfpGNNeAAAAGXN0cm9rZVN0eWxlTGluZURhc2hP"
"ZmZzZXRVbnRGI1BudAAAAAAAAAAAAAAAFXN0cm9rZVN0eWxlTWl0ZXJMaW1pdGRvdWJAWQAAAAAAAAAAABZzdHJva2VTdHlsZUxpbmVDYXBUeX"
"BlZW51bQAAABZzdHJva2VTdHlsZUxpbmVDYXBUeXBlAAAAEnN0cm9rZVN0eWxlQnV0dENhcAAAABdzdHJva2VTdHlsZUxpbmVKb2luVHlwZWVu"
"dW0AAAAXc3Ryb2tlU3R5bGVMaW5lSm9pblR5cGUAAAAUc3Ryb2tlU3R5bGVNaXRlckpvaW4AAAAYc3Ryb2tlU3R5bGVMaW5lQWxpZ25tZW50ZW"
"51bQAAABhzdHJva2VTdHlsZUxpbmVBbGlnbm1lbnQAAAAWc3Ryb2tlU3R5bGVBbGlnbkNlbnRlcgAAABRzdHJva2VTdHlsZVNjYWxlTG9ja2Jv"
"b2wAAAAAF3N0cm9rZVN0eWxlU3Ryb2tlQWRqdXN0Ym9vbAAAAAAWc3Ryb2tlU3R5bGVMaW5lRGFzaFNldFZsTHMAAAAAAAAAFHN0cm9rZVN0eW"
"xlQmxlbmRNb2RlZW51bQAAAABCbG5NAAAAAE5ybWwAAAASc3Ryb2tlU3R5bGVPcGFjaXR5VW50RiNQcmNAWQAAAAAAAAAAABJzdHJva2VTdHls"
"ZUNvbnRlbnRPYmpjAAAAAQAAAAAAD3NvbGlkQ29sb3JMYXllcgAAAAEAAAAAQ2xyIE9iamMAAAABAAAAAAAAUkdCQwAAAAMAAAAAUmQgIGRvdW"
"JAb+AAAAAAAAAAAABHcm4gZG91YkBv4AAAAAAAAAAAAEJsICBkb3ViAAAAAAAAAAAAAAAVc3Ryb2tlU3R5bGVSZXNvbHV0aW9uZG91YkBSAAAA"
"AAAA")},
};
}
}
+248
View File
@@ -0,0 +1,248 @@
#include "IO/PSD/PSDDocumentBuilder.h"
#include "IO/PSD/PSDVector.h"
#include "PSDFixture.h"
#include "PSDVectorBuilders.h"
#include "PSDVectorFixtures.h"
#include <QtTest>
// Swift's vector tests: shapes live, masks rasterized, sizes refused.
using namespace PSDVectorBuilders;
namespace {
std::vector<QPointF> box()
{
return {QPointF(120, 30), QPointF(120, 80), QPointF(20, 80), QPointF(20, 30)};
}
std::optional<ImageImportError::Kind> refusal(const std::function<void()> &run)
{
try {
run();
} catch (const ImageImportError &error) {
return error.kind;
}
return std::nullopt;
}
PSDRecord shapeRecord(const QString &name, const PSDVector::Live &live)
{
PSDRecord record = PSDFixture::record(name, live.image, live.bounds);
record.kind = PSDLayerKind::vector;
record.shape = live.style;
record.shapeNotes = live.notes;
return record;
}
}
class PSDVectorTests : public QObject {
Q_OBJECT
private slots:
void vectorMaskIsRasterizedWithFillAndStroke();
void photoshopShapeExtrasRasterizeInPlace();
void fillEllipseImportsAsALiveShape();
void strokedRectangleImportsAsALiveShapeAndReportsTheStroke();
void fourSharpCornersInferARectangleWithoutOrigination();
void hugeOriginationSizeIsRejectedWithoutTrapping();
void nonFiniteOriginationSizeIsIgnored();
void hugeStrokeWidthIsRejectedWithoutTrapping();
void sidesAndBudgetsRefuseAtTheirEdges();
void knotCountsAndCurvesDecideTheShape();
void subpathsCloseOpenAndStandApart();
void fillsWindAntialiasAndMitersKeepTheirTips();
};
void PSDVectorTests::vectorMaskIsRasterizedWithFillAndStroke()
{
const QSizeF canvas(200, 200);
PSDVector::Extra extra{{"vmsk", vectorMask(canvas, box())},
{"SoCo", colorDescriptor(0, 110, 255)},
{"vstk", strokeStyle(true, false, 1, 255, 255, 0)}};
const PSDVector::Raster raster = PSDVector::raster(extra, canvas).value();
// Fractions of 2^24 fall short: the box floors outward.
QCOMPARE(raster.bounds, QRectF(19, 29, 101, 51));
QCOMPARE(raster.image.size(), QSize(101, 51));
QCOMPARE(raster.image.pixel(50, 25), qRgb(0, 110, 255));
extra["vstk"] = strokeStyle(true, true, 10, 255, 255, 0);
extra["SoCo"] = colorDescriptor(0, 0, 0);
const PSDVector::Raster stroked = PSDVector::raster(extra, canvas).value();
// Half the width and a pixel more on each side.
QCOMPARE(stroked.bounds, QRectF(13, 23, 113, 63));
QCOMPARE(stroked.image.pixel(56, 31), qRgb(0, 0, 0));
QCOMPARE(stroked.image.pixel(56, 7), qRgb(255, 255, 0));
// A miter keeps the square corner; a bevel would not.
QCOMPARE(stroked.image.pixel(2, 2), qRgb(255, 255, 0));
}
void PSDVectorTests::photoshopShapeExtrasRasterizeInPlace()
{
const PSDVector::Raster circle = PSDVector::raster(PSDVectorFixtures::circle(), PSDVectorFixtures::canvas).value();
QVERIFY(std::abs(circle.bounds.center().x() - 618) < 8);
QVERIFY(std::abs(circle.bounds.center().y() - 677) < 8);
QVERIFY(std::abs(circle.bounds.width() - 328) < 12);
QVERIFY(std::abs(circle.bounds.height() - 328) < 12);
const PSDVector::Raster rectangle = PSDVector::raster(PSDVectorFixtures::rectangle(), PSDVectorFixtures::canvas).value();
QVERIFY(rectangle.bounds.width() > 640);
QVERIFY(rectangle.bounds.height() > 170);
QVERIFY(std::abs(rectangle.bounds.center().x() - 1268) < 20);
QVERIFY(std::abs(rectangle.bounds.center().y() - 244) < 20);
}
void PSDVectorTests::fillEllipseImportsAsALiveShape()
{
PSDVector::Extra extra = PSDVectorFixtures::circle();
extra["vogk"] = originationData(5, QRectF(454, 513, 328, 328));
const PSDVector::Live live = PSDVector::live(extra, PSDVectorFixtures::canvas).value();
QVERIFY(live.style.kind == ShapeKind::ellipse);
QVERIFY(std::abs(live.style.green - 110 / 255.0) < 0.01);
QVERIFY(std::abs(live.style.blue - 1) < 0.01);
QVERIFY(live.notes.empty());
QCOMPARE(live.bounds, QRectF(454, 513, 328, 328));
QCOMPARE(live.image.size(), QSize(328, 328));
const PSDImport imported = PSDDocumentBuilder::makeImport(PSDDocument{1920, 1080, 72, {shapeRecord("cercle-bleu", live)}});
QVERIFY(imported.layers[0].liveShape().value().style.kind == ShapeKind::ellipse);
QVERIFY(imported.conversions.empty());
QCOMPARE(imported.layers[0].liveShape().value().image, imported.layers[0].asset.value().identity());
}
void PSDVectorTests::strokedRectangleImportsAsALiveShapeAndReportsTheStroke()
{
PSDVector::Extra extra = PSDVectorFixtures::rectangle();
extra["vogk"] = originationData(2, QRectF(945, 153, 646, 182), {0, 0, 0, 0});
const PSDVector::Live live = PSDVector::live(extra, PSDVectorFixtures::canvas).value();
QVERIFY(live.style.kind == ShapeKind::rectangle);
QCOMPARE(live.style.cornerRadius, 0.0);
QCOMPARE(live.notes, std::vector<QString>{"The Photoshop stroke isn’t supported on shape layers and was omitted."});
const PSDImport imported = PSDDocumentBuilder::makeImport(PSDDocument{1920, 1080, 72, {shapeRecord("rectangle-contour-jaune", live)}});
QVERIFY(imported.layers[0].liveShape().value().style.kind == ShapeKind::rectangle);
QCOMPARE(imported.conversions.size(), size_t(1));
QCOMPARE(imported.conversions[0].layerName, QString("rectangle-contour-jaune"));
QVERIFY(imported.conversions[0].message.contains("stroke"));
// Radii within half a pixel round by the largest.
extra["vogk"] = originationData(2, QRectF(945, 153, 646, 182), {12, 12.4, 12, 12});
QCOMPARE(PSDVector::live(extra, PSDVectorFixtures::canvas).value().style.cornerRadius, 12.4);
// Wider apart they refuse; the four sharp anchors decide instead.
extra["vogk"] = originationData(2, QRectF(945, 153, 646, 182), {12, 12.6, 12, 12});
const PSDVector::Live sharp = PSDVector::live(extra, PSDVectorFixtures::canvas).value();
QCOMPARE(sharp.style.cornerRadius, 0.0);
QVERIFY(sharp.bounds != QRectF(945, 153, 646, 182));
}
void PSDVectorTests::fourSharpCornersInferARectangleWithoutOrigination()
{
const PSDVector::Extra extra{{"vmsk", vectorMask(QSizeF(200, 200), box())},
{"SoCo", colorDescriptor(0, 110, 255)},
{"vstk", strokeStyle(true, false, 1, 255, 255, 0)}};
const PSDVector::Live live = PSDVector::live(extra, QSizeF(200, 200)).value();
QVERIFY(live.style.kind == ShapeKind::rectangle);
QVERIFY(live.notes.empty());
QCOMPARE(live.bounds, QRectF(19, 29, 101, 51));
}
void PSDVectorTests::hugeOriginationSizeIsRejectedWithoutTrapping()
{
const PSDVector::Extra extra{{"vogk", originationData(5, QRectF(0, 0, 1e20, 1e20))},
{"SoCo", colorDescriptor(0, 110, 255)},
{"vstk", strokeStyle(true, false, 1, 255, 255, 0)}};
QCOMPARE(refusal([&] { PSDVector::live(extra, PSDVectorFixtures::canvas); }), std::optional(ImageImportError::Kind::tooLarge));
}
void PSDVectorTests::nonFiniteOriginationSizeIsIgnored()
{
const PSDVector::Extra extra{{"vogk", originationData(5, QRectF(10, 10, qInf(), 100))},
{"SoCo", colorDescriptor(0, 110, 255)},
{"vstk", strokeStyle(true, false, 1, 255, 255, 0)}};
QVERIFY(!PSDVector::live(extra, PSDVectorFixtures::canvas));
}
void PSDVectorTests::hugeStrokeWidthIsRejectedWithoutTrapping()
{
const PSDVector::Extra extra{{"vmsk", vectorMask(QSizeF(200, 200), box())},
{"SoCo", colorDescriptor(0, 0, 0)},
{"vstk", strokeStyle(true, true, 1e20, 255, 255, 0)}};
QCOMPARE(refusal([&] { PSDVector::raster(extra, QSizeF(200, 200)); }), std::optional(ImageImportError::Kind::tooLarge));
}
void PSDVectorTests::sidesAndBudgetsRefuseAtTheirEdges()
{
const auto filled = [](QSizeF canvas, const std::vector<QPointF> &corners) {
return PSDVector::Extra{{"vmsk", vectorMask(canvas, corners)}, {"SoCo", colorDescriptor(0, 0, 0)}};
};
// A side past 30,000 pixels, however few pixels in all.
const QSizeF wide(40'000, 10);
const PSDVector::Extra long_ = filled(wide, {QPointF(0, 0), QPointF(30'001, 0), QPointF(30'001, 1), QPointF(0, 1)});
QCOMPARE(refusal([&] { PSDVector::raster(long_, wide); }), std::optional(ImageImportError::Kind::tooLarge));
// The budget holds at its edge: 101 by 51 pixels.
const PSDVector::Extra extra = filled(QSizeF(200, 200), box());
QCOMPARE(PSDVector::raster(extra, QSizeF(200, 200), 5151).value().image.size(), QSize(101, 51));
QCOMPARE(refusal([&] { PSDVector::raster(extra, QSizeF(200, 200), 5150); }), std::optional(ImageImportError::Kind::tooLarge));
// A lone anchor still spends one pixel of the budget.
const PSDVector::Extra dot = filled(QSizeF(200, 200), {QPointF(10.5, 10.5)});
QCOMPARE(PSDVector::raster(dot, QSizeF(200, 200), 1).value().image.size(), QSize(1, 1));
QCOMPARE(refusal([&] { PSDVector::raster(dot, QSizeF(200, 200), 0); }), std::optional(ImageImportError::Kind::tooLarge));
// A stroke narrower than nothing is refused.
const PSDVector::Extra negative{{"vmsk", vectorMask(QSizeF(200, 200), box())}, {"vstk", strokeStyle(false, true, -5, 255, 0, 0)}};
QCOMPARE(refusal([&] { PSDVector::raster(negative, QSizeF(200, 200)); }), std::optional(ImageImportError::Kind::tooLarge));
}
void PSDVectorTests::knotCountsAndCurvesDecideTheShape()
{
const QSizeF canvas(200, 200);
// A knot past the declared count stays out.
std::vector<QPointF> five = box();
five.push_back(QPointF(190, 190));
const PSDVector::Extra counted{{"vmsk", vectorMask(canvas, {Subpath{five, true, 4}})}, {"SoCo", colorDescriptor(0, 110, 255)}};
QCOMPARE(PSDVector::live(counted, canvas).value().bounds, QRectF(19, 29, 101, 51));
QCOMPARE(PSDVector::raster(counted, canvas).value().bounds, QRectF(19, 29, 101, 51));
// Handles off their anchors make curves, which infer no rectangle.
const PSDVector::Extra curved{{"vmsk", vectorMask(canvas, {Subpath{box(), true, std::nullopt, QPointF(5, 0)}})}, {"SoCo", colorDescriptor(0, 110, 255)}};
QVERIFY(!PSDVector::live(curved, canvas));
QVERIFY(PSDVector::raster(curved, canvas).value().bounds.width() > 101);
}
void PSDVectorTests::subpathsCloseOpenAndStandApart()
{
const QSizeF canvas(200, 200);
const std::vector<QPointF> far{QPointF(190, 150), QPointF(190, 190), QPointF(150, 190), QPointF(150, 150)};
const auto stroked = [&](const std::vector<Subpath> &subpaths) {
return PSDVector::raster({{"vmsk", vectorMask(canvas, subpaths)}, {"vstk", strokeStyle(false, true, 4, 255, 0, 0)}}, canvas).value();
};
const auto ink = [](const PSDVector::Raster &raster, QPointF at) { return qAlpha(raster.image.pixel((at - raster.bounds.topLeft()).toPoint())); };
// A closed subpath strokes its closing edge; open ones not.
const PSDVector::Raster both = stroked({Subpath{box()}, Subpath{far}});
QCOMPARE(ink(both, QPointF(70, 30)), 255);
QCOMPARE(ink(both, QPointF(170, 150)), 255);
const PSDVector::Raster open = stroked({Subpath{box(), false}, Subpath{far}});
QCOMPARE(ink(open, QPointF(70, 30)), 0);
QCOMPARE(ink(open, QPointF(120, 55)), 255);
// Each subpath begins anew: nothing joins them.
QCOMPARE(ink(both, QPointF(105, 90)), 0);
}
void PSDVectorTests::fillsWindAntialiasAndMitersKeepTheirTips()
{
const QSizeF canvas(200, 200);
// Two squares turning the same way: their overlap stays filled.
const std::vector<QPointF> first{QPointF(20, 20), QPointF(80, 20), QPointF(80, 80), QPointF(20, 80)};
const std::vector<QPointF> second{QPointF(50, 50), QPointF(110, 50), QPointF(110, 110), QPointF(50, 110)};
const PSDVector::Raster overlap =
PSDVector::raster({{"vmsk", vectorMask(canvas, {Subpath{first}, Subpath{second}})}, {"SoCo", colorDescriptor(0, 0, 0)}}, canvas).value();
QCOMPARE(qAlpha(overlap.image.pixel((QPointF(65, 65) - overlap.bounds.topLeft()).toPoint())), 255);
// A circle's rim takes partial coverage.
const QImage circle = PSDVector::raster(PSDVectorFixtures::circle(), PSDVectorFixtures::canvas).value().image;
int partial = 0;
for (int y = 0; y < circle.height(); ++y)
for (int x = 0; x < circle.width(); ++x)
partial += qAlpha(circle.pixel(x, y)) > 0 && qAlpha(circle.pixel(x, y)) < 255;
QVERIFY2(partial > 200, qPrintable(QString::number(partial)));
// A 30° apex keeps its miter at limit 10.
const double reach = 100 * std::tan(15 * M_PI / 180);
const std::vector<QPointF> apex{QPointF(100, 20), QPointF(100 + reach, 120), QPointF(100 - reach, 120)};
const std::vector<QPointF> spot{QPointF(100, 0), QPointF(101, 0), QPointF(101, 1), QPointF(100, 1)};
const PSDVector::Raster tip =
PSDVector::raster({{"vmsk", vectorMask(canvas, {Subpath{apex}, Subpath{spot}})}, {"vstk", strokeStyle(false, true, 4, 255, 0, 0)}}, canvas).value();
// The tip's edge crosses this pixel; a bevel clears it.
QVERIFY(qAlpha(tip.image.pixel((QPointF(100, 14) - tip.bounds.topLeft()).toPoint())) > 100);
}
QTEST_GUILESS_MAIN(PSDVectorTests)
#include "PSDVectorTests.moc"