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