Read Photoshop Large Documents, layerless files and oversized layers (12.11b1)

This commit is contained in:
雷电芽衣 committed 2026-09-29 04:23:27 -04:00
1 parent 8a9c56ee1f
commit a2241601ec
30 files changed
+1228 -385

No files matched your search

+1 -1
View File
@@ -323,7 +323,7 @@ void ContentViewTests::theImporterPicksFilesAndClearsItsFlag()
QTest::qWait(50);
QCOMPARE(int(session.document().value().layers.size()), 2);
desk.typeBeforeCancel.clear();
const QString panel = "panel|Open|open|file|||1 many Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd " + RawImporter::globs().join(" ") + ")";
const QString panel = "panel|Open|open|file|||1 many Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd *.psb " + RawImporter::globs().join(" ") + ")";
QCOMPARE(desk.seen.mid(0, 2), (QStringList{panel, panel}));
QCOMPARE(desk.seen.size(), 3);
// The flag cleared from elsewhere takes the panel down.
+206
View File
@@ -0,0 +1,206 @@
#include "IO/PSD/PSDChannelCoder.h"
#include "IO/PSD/PSDDocumentBuilder.h"
#include "IO/PSD/PSDReader.h"
#include "AddressSpaceLimit.h"
#include "PSDReaderFixtures.h"
#include <QtTest>
// Swift 1.2.10 (1fcf854): oversized layers cut to the canvas.
namespace {
QImage rgbaImage(int width, int height)
{
std::vector<uchar> red, green, blue, alpha;
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
red.push_back(uchar(x * 30 + y));
green.push_back(uchar(y * 50));
blue.push_back(uchar(255 - x * 20));
alpha.push_back(255);
}
}
return PSDChannelCoder::rgbaImage(width, height, red, green, blue, alpha);
}
QImage grayImage(int width, int height)
{
std::vector<uchar> gray;
for (int index = 0; index < width * height; ++index)
gray.push_back(uchar(index * 10));
return PSDChannelCoder::maskImage(width, height, gray);
}
// A masked layer hanging off a 4 by 4 canvas.
PSDDocument overhang()
{
PSDRecord layer = PSDFixture::record(QStringLiteral("Overhang"), rgbaImage(6, 3), QRectF(-2, 1, 6, 3));
layer.mask = grayImage(6, 3);
return PSDDocument{4, 4, 72, {layer}};
}
QByteArray file(const PSDDocument &document)
{
return PSDFixture::data(document, rgbaImage(4, 4));
}
bool cropNoted(const PSDDocument &parsed, const QString &name)
{
const PSDImport imported = PSDDocumentBuilder::makeImport(parsed, PSDDocumentBuilder::assets(parsed));
return std::any_of(imported.conversions.begin(), imported.conversions.end(), [&](const PSDConversion &note) {
return note.layerName == name && note.message.contains(QStringLiteral("Cropped to the canvas"));
});
}
std::vector<uchar> sliced(const std::vector<uchar> &plane, int width, const PSDCrop &crop)
{
std::vector<uchar> result;
for (int row = 0; row < crop.height; ++row)
result.insert(result.end(), plane.begin() + (crop.y + row) * width + crop.x, plane.begin() + (crop.y + row) * width + crop.x + crop.width);
return result;
}
}
class CropToCanvasImportTests : public QObject {
Q_OBJECT
private slots:
void fittingDocumentKeepsOversizedLayerPixels();
void overBudgetDocumentCropsImageAndMaskToCanvas();
void croppedDocumentStillOverBudgetIsRejected();
void offCanvasLayerIsKeptWithoutPixelsWhenCropping();
void channelCropsMatchFullDecodes();
void theLayersAreSummedBeforeAnyCut();
void aSourcePastIntStaysWide();
void theCutIsJudgedBeforeAnyPixel();
void anInvertedRecordIsCutToNothing();
};
void CropToCanvasImportTests::fittingDocumentKeepsOversizedLayerPixels()
{
const PSDDocument source = overhang();
const PSDDocument parsed = PSDReader::read(file(source), 100);
const PSDRecord &layer = parsed.layers.at(0);
QCOMPARE(layer.bounds, QRectF(-2, 1, 6, 3));
QVERIFY(!layer.croppedToCanvas);
QCOMPARE(layer.image.value(), source.layers[0].image.value());
QVERIFY(!cropNoted(parsed, QStringLiteral("Overhang")));
}
void CropToCanvasImportTests::overBudgetDocumentCropsImageAndMaskToCanvas()
{
// Eighteen pixels are past twelve; the canvas's twelve fit.
const PSDDocument source = overhang();
const PSDDocument parsed = PSDReader::read(file(source), 12);
const PSDRecord &layer = parsed.layers.at(0);
QCOMPARE(layer.bounds, QRectF(0, 1, 4, 3));
QVERIFY(layer.croppedToCanvas);
QCOMPARE(layer.image.value(), source.layers[0].image.value().copy(2, 0, 4, 3));
QCOMPARE(layer.mask.value(), source.layers[0].mask.value().copy(2, 0, 4, 3));
QVERIFY(cropNoted(parsed, QStringLiteral("Overhang")));
}
void CropToCanvasImportTests::croppedDocumentStillOverBudgetIsRejected()
{
try {
PSDReader::read(file(overhang()), 11);
QFAIL("a file past the budget once cut was read");
} catch (const ImageImportError &error) {
QCOMPARE(error.kind, ImageImportError::Kind::tooLarge);
}
}
void CropToCanvasImportTests::offCanvasLayerIsKeptWithoutPixelsWhenCropping()
{
// Past the right edge, and wholly before the left.
const PSDRecord right = PSDFixture::record(QStringLiteral("Outside"), rgbaImage(2, 2), QRectF(5, 0, 2, 2));
const PSDRecord left = PSDFixture::record(QStringLiteral("Before"), rgbaImage(2, 2), QRectF(-5, 0, 2, 2));
const PSDDocument parsed = PSDReader::read(file(PSDDocument{4, 4, 72, {right, left}}), 1);
for (const PSDRecord &layer : parsed.layers)
QVERIFY(!layer.image && layer.croppedToCanvas);
QVERIFY(cropNoted(parsed, QStringLiteral("Outside")) && cropNoted(parsed, QStringLiteral("Before")));
}
void CropToCanvasImportTests::channelCropsMatchFullDecodes()
{
const int width = 5, height = 4;
QByteArray source;
for (int index = 0; index < width * height; ++index)
source.append(char(index));
const PSDCrop crop{1, 1, 3, 2};
const std::vector<uchar> raw = PSDChannelCoder::decode(0, width, height, source);
QCOMPARE(PSDChannelCoder::decode(0, width, height, source, false, crop), sliced(raw, width, crop));
for (const bool large : {false, true}) {
std::vector<uchar> plane(source.begin(), source.end());
const QByteArray packed = PSDFixture::encode(plane, width, height, large);
const std::vector<uchar> full = PSDChannelCoder::decode(1, width, height, packed, large);
QCOMPARE(full, raw);
QCOMPARE(PSDChannelCoder::decode(1, width, height, packed, large, crop), sliced(full, width, crop));
}
}
void CropToCanvasImportTests::theLayersAreSummedBeforeAnyCut()
{
// Each fits thirty alone, not both: cut, they do.
PSDDocument document = overhang();
document.layers[0].mask.reset();
document.layers.insert(document.layers.begin(), PSDFixture::record(QStringLiteral("Whole"), rgbaImage(4, 4), QRectF(0, 0, 4, 4)));
const PSDDocument parsed = PSDReader::read(file(document), 30);
QVERIFY(!parsed.layers.at(0).croppedToCanvas && parsed.layers.at(1).croppedToCanvas);
QCOMPARE(parsed.layers.at(1).bounds, QRectF(0, 1, 4, 3));
}
void CropToCanvasImportTests::aSourcePastIntStaysWide()
{
// A source 2^31 wide, cut to a one-pixel canvas.
for (const char *payload : {"\0\0", "\0\1"}) {
PSDReaderFixtures::Record record;
record.bounds = QRect(0, 0, 1, 1);
record.channels = {{0, QByteArray(payload, 2)}};
QByteArray data = PSDReaderFixtures::file({record}, {}, 1, 1);
data.replace(48, 4, QByteArray::fromHex("ffffffff"));
data.replace(56, 4, QByteArray::fromHex("7fffffff"));
// As tall too: its row counts judged before held.
data.replace(44, 4, QByteArray::fromHex("ffffffff"));
data.replace(52, 4, QByteArray::fromHex("7fffffff"));
std::optional<PSDError::Kind> kind;
{
// Nothing the size of the source may be held.
const AddressSpaceLimit limit(64ll * 1024 * 1024);
try {
PSDReader::read(data, 1);
} catch (const PSDError &error) {
kind = error.kind;
}
}
QCOMPARE(kind, std::optional(PSDError::Kind::truncated));
}
}
void CropToCanvasImportTests::theCutIsJudgedBeforeAnyPixel()
{
// Two pixels in one: refused before the truncated first.
PSDReaderFixtures::Record first, second;
first.bounds = second.bounds = QRect(0, 0, 1, 1);
first.channels = {{0, QByteArray("\0\0", 2)}};
second.channels = {{0, QByteArray("\0\0\x80", 3)}};
try {
PSDReader::read(PSDReaderFixtures::file({first, second}, {}, 1, 1), 1);
QFAIL("two pixels were read in a budget of one");
} catch (const ImageImportError &error) {
QCOMPARE(error.kind, ImageImportError::Kind::tooLarge);
}
}
void CropToCanvasImportTests::anInvertedRecordIsCutToNothing()
{
// Right before left: cut to nothing, and reported.
PSDReaderFixtures::Record inverted, wide;
inverted.bounds = QRect(2, 0, -1, 1);
wide.bounds = QRect(-1, 0, 5, 1);
PSDReaderFixtures::solidChannels(wide.bounds, wide.channels);
const PSDDocument parsed = PSDReader::read(PSDReaderFixtures::file({inverted, wide}, {}, 4, 1), 4);
QVERIFY(parsed.layers.at(0).croppedToCanvas && parsed.layers.at(1).croppedToCanvas);
QCOMPARE(parsed.layers.at(0).bounds, QRectF(2, 0, 0, 1));
QCOMPARE(parsed.layers.at(1).bounds, QRectF(0, 0, 4, 1));
}
QTEST_GUILESS_MAIN(CropToCanvasImportTests)
#include "CropToCanvasImportTests.moc"
+8 -2
View File
@@ -70,7 +70,7 @@ void DesktopIntegrationTests::theEntryOpensSwiftsDocumentTypes()
QCOMPARE(keys.value("Terminal"), QString("false"));
QVERIFY(keys.value("Categories").split(QLatin1Char(';')).contains("Graphics"));
// Swift's two document types: its projects, then four pictures.
QCOMPARE(keys.value("MimeType"), QString("application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-dcraw;"));
QCOMPARE(keys.value("MimeType"), QString("application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-photoshop-large-image;image/x-dcraw;"));
}
void DesktopIntegrationTests::aProjectFolderIsAType()
@@ -87,9 +87,15 @@ void DesktopIntegrationTests::aProjectFolderIsAType()
seen << "glob:" + xml.attributes().value("pattern").toString();
else if (name == QLatin1String("comment"))
seen << "comment:" + xml.readElementText();
else if (name == QLatin1String("magic"))
seen << "magic:" + xml.attributes().value("priority").toString();
else if (name == QLatin1String("match"))
seen << "match:" + xml.attributes().value("value").toString() + "@" + xml.attributes().value("offset").toString();
}
QVERIFY2(!xml.hasError(), qPrintable(xml.errorString()));
QCOMPARE(seen, (QStringList{"mime-type:application/x-compositor-project", "comment:OmaPhoto Project", "sub-class-of:inode/directory", "glob:*.comp"}));
QCOMPARE(seen, (QStringList{"mime-type:application/x-compositor-project", "comment:OmaPhoto Project", "sub-class-of:inode/directory", "glob:*.comp",
"mime-type:image/x-photoshop-large-image", "comment:Photoshop Large Document", "glob:*.psb", "magic:50",
"match:8BPS\\x00\\x02@0"}));
}
void DesktopIntegrationTests::anInstallLaysDownTheAppItsEntryIconsAndLicense()
+4
View File
@@ -154,9 +154,13 @@ void ImageFileDropTests::aPictureIsCopiedToAFileFirst()
raw.setData("image/gif", "any picture last");
raw.setData("image/x-adobe-dng", DNGFixture::data({}));
QCOMPARE(ImageFileDrop::providers(raw).front()->formats(), QStringList{"image/x-adobe-dng"});
// A Large Document: after a PSD, before RAW and others.
raw.setData("image/x-photoshop-large-image", "large first");
QCOMPARE(ImageFileDrop::providers(raw).front()->formats(), QStringList{"image/x-photoshop-large-image"});
raw.setData("image/vnd.adobe.photoshop", "photoshop first");
QCOMPARE(ImageFileDrop::providers(raw).front()->formats(), QStringList{"image/vnd.adobe.photoshop"});
raw.removeFormat("image/vnd.adobe.photoshop");
raw.removeFormat("image/x-photoshop-large-image");
EditorSession developed;
done = false;
ImageFileDrop::importProviders(raw, developed, std::nullopt, nullptr, std::nullopt, [&done] { done = true; });
+254
View File
@@ -0,0 +1,254 @@
#include "AddressSpaceLimit.h"
#include "Document/EditorSession.h"
#include "IO/ImageImporter.h"
#include "IO/PSD/PSDChannelCoder.h"
#include "IO/PSD/PSDReader.h"
#include "PSDFixture.h"
#include <QColorSpace>
#include <QTemporaryDir>
#include <QtTest>
// Swift 1.2.10: Large Documents, and files of a background alone.
namespace {
QString written(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 file.fileName();
}
// Photoshop's ICC resource, 1039, added after the others.
QByteArray withProfile(QByteArray file, const QByteArray &profile)
{
PSDFixture::Buffer resource;
resource.string("8BIM");
resource.u16(1039);
resource.u16(0);
resource.u32(quint32(profile.size()));
resource.bytes(profile);
if (profile.size() % 2 == 1)
resource.u8(0);
// The resources' length sits after the header and colour data.
const quint32 length = quint32(uchar(file[30])) << 24 | quint32(uchar(file[31])) << 16 | quint32(uchar(file[32])) << 8 | uchar(file[33]);
PSDFixture::Buffer grown;
grown.u32(length + quint32(resource.data.size()));
file.replace(30, 4, grown.data);
file.insert(34 + qsizetype(length), resource.data);
return file;
}
}
class PSBImportTests : public QObject {
Q_OBJECT
private slots:
void PSBRoundTripMatchesPSDLayerContent();
void PSBExceedingCanvasLimitIsRejected();
void PSBLargeAdditionalInfoBlockDoesNotHideUnicodeName();
void aLayerlessFileReadsAsItsMergedImage();
void theMergedImageTakesItsProfileAndTheBudget();
void aLayerlessFileImportsAsOneLayer();
void aMergedImageIsJudgedBeforeTheFileIsRead();
void theReadingEndsBeforeTheMergedRead();
void aLayeredReadSkipsTheProfile();
};
void PSBImportTests::PSBRoundTripMatchesPSDLayerContent()
{
PSDRecord bottom = PSDFixture::record(QStringLiteral("Red"), PSDFixture::colorImage(3, 2, 1, 0, 0), QRectF(0, 0, 3, 2));
PSDRecord middle = PSDFixture::record(QStringLiteral("Green"), PSDFixture::colorImage(2, 3, 0, 1, 0), QRectF(1, 1, 2, 3));
PSDRecord top = PSDFixture::record(QStringLiteral("Blue mask"), PSDFixture::colorImage(2, 2, 0, 0, 1), QRectF(2, 2, 2, 2));
top.mask = PSDFixture::grayImage(2, 2, 128);
const PSDDocument source{5, 5, 144, {bottom, middle, top}};
const QImage composite = PSDFixture::colorImage(5, 5, 0, 0, 0);
const PSDDocument psd = PSDReader::read(PSDFixture::data(source, composite));
const PSDDocument psb = PSDReader::read(PSDFixture::data(source, composite, true));
QCOMPARE(psb.layers.size(), psd.layers.size());
for (size_t index = 0; index < psd.layers.size(); ++index) {
QCOMPARE(psb.layers[index].name, psd.layers[index].name);
QCOMPARE(psb.layers[index].bounds, psd.layers[index].bounds);
QCOMPARE(psb.layers[index].image.value(), psd.layers[index].image.value());
}
QCOMPARE(psb.layers[2].mask.value(), psd.layers[2].mask.value());
QCOMPARE(psb.resolution, 144.0);
}
void PSBImportTests::PSBExceedingCanvasLimitIsRejected()
{
const QImage image = PSDFixture::colorImage(2, 2, 1, 0, 0);
QByteArray data = PSDFixture::data(PSDDocument{2, 2, 72, {PSDFixture::record(QStringLiteral("Large"), image, QRectF(0, 0, 2, 2))}}, image, true);
data.replace(18, 4, QByteArray("\0\0\x75\x31", 4));
try {
PSDReader::read(data);
QFAIL("a Large Document past the side limit was read");
} catch (const ImageImportError &error) {
QCOMPARE(error.kind, ImageImportError::Kind::tooLarge);
}
}
void PSBImportTests::PSBLargeAdditionalInfoBlockDoesNotHideUnicodeName()
{
const QImage image = PSDFixture::colorImage(2, 2, 1, 0, 0);
const PSDRecord layer = PSDFixture::record(QStringLiteral("Café layer"), image, QRectF(0, 0, 2, 2));
const QByteArray data =
PSDFixture::data(PSDDocument{2, 2, 72, {layer}}, image, true, PSDFixture::AdditionalLayerInfo{"LMsk", QByteArray("\1\2\3", 3)});
QCOMPARE(PSDReader::read(data).layers.at(0).name, QStringLiteral("Café layer"));
}
void PSBImportTests::aLayerlessFileReadsAsItsMergedImage()
{
// No layer records; each channel's rows pack apart.
const QTemporaryDir folder;
std::vector<uchar> red, green, blue;
for (int index = 0; index < 15; ++index) {
red.push_back(uchar(index * 17));
green.push_back(uchar(index < 8 ? 40 : 200));
blue.push_back(uchar(90));
}
const QImage composite = PSDChannelCoder::rgbaImage(5, 3, red, green, blue, std::vector<uchar>(15, 255));
for (const bool large : {false, true}) {
const QString path = written(folder, large ? "Flat.psb" : "Flat.psd", PSDFixture::data(PSDDocument{5, 3, 72, {}}, composite, large));
QVERIFY(PSDReader::read(path).layers.empty());
const QImage merged = PSDReader::merged(path);
QCOMPARE(merged, composite);
QCOMPARE(merged.colorSpace(), QColorSpace(QColorSpace::SRgb));
}
}
void PSBImportTests::theMergedImageTakesItsProfileAndTheBudget()
{
const QTemporaryDir folder;
const QImage composite = PSDFixture::colorImage(5, 3, 0.5, 0.25, 0.125);
const QByteArray flat = PSDFixture::data(PSDDocument{5, 3, 72, {}}, composite);
// Fifteen pixels against fourteen: refused before any pixel.
try {
PSDReader::merged(written(folder, "Flat.psd", flat), 14);
QFAIL("a merged image past the budget was read");
} catch (const ImageImportError &error) {
QCOMPARE(error.kind, ImageImportError::Kind::tooLarge);
}
// Past one surface yet in budget: read on, truncated.
if (DocumentLimits::documentPixelBudget() > qint64(20'001) * 10'000) {
PSDFixture::Buffer header;
header.string("8BPS");
header.u16(1);
header.bytes(QByteArray(6, '\0'));
header.u16(3);
header.u32(10'000);
header.u32(20'001);
header.u16(8);
header.u16(3);
header.bytes(QByteArray(12, '\0'));
header.u16(1);
try {
PSDReader::merged(written(folder, "Wide.psd", header.data));
QFAIL("a header alone was read");
} catch (const PSDError &error) {
QCOMPARE(error.kind, PSDError::Kind::truncated);
}
}
// Display P3 numbers arrive as the sRGB they name.
const QString tagged = written(folder, "P3.psd", withProfile(flat, QColorSpace(QColorSpace::DisplayP3).iccProfile()));
QCOMPARE(PSDReader::merged(tagged).colorSpace(), QColorSpace(QColorSpace::DisplayP3));
const ImportedImage asset = ImageImporter::decode(tagged, DocumentLimits::documentPixelBudget(), true);
const QColor expected = QColorSpace(QColorSpace::DisplayP3).transformationToColorSpace(QColorSpace::SRgb).map(composite.pixelColor(0, 0));
const QColor got = asset.image().pixelColor(0, 0);
QVERIFY2(std::abs(got.red() - expected.red()) <= 1 && std::abs(got.green() - expected.green()) <= 1 && std::abs(got.blue() - expected.blue()) <= 1,
qPrintable(got.name() + " " + expected.name()));
QVERIFY(got != composite.pixelColor(0, 0));
}
void PSBImportTests::aLayerlessFileImportsAsOneLayer()
{
const QTemporaryDir folder;
const QImage composite = PSDFixture::colorImage(5, 3, 0, 0.5, 1);
const QString path = written(folder, "Background.psb", PSDFixture::data(PSDDocument{5, 3, 72, {}}, composite, true));
EditorSession session;
session.createDocument(8, 8);
bool done = false;
session.importImages({QUrl::fromLocalFile(path)}, QPointF(6, 5), [&done] { done = true; });
QVERIFY(QTest::qWaitFor([&done] { return done; }, 20'000));
// No conversion sheet: the merged image comes straight in.
QVERIFY(!session.showsConversionSheet() && !session.importError());
QCOMPARE(session.document().value().layers.size(), size_t(1));
const ImageLayer layer = session.activeLayer().value();
QCOMPARE(layer.name, QString("Background"));
QCOMPARE(layer.asset.value().image().pixelColor(2, 1), composite.pixelColor(2, 1));
// Dropped at a point, it lands there.
const QPointF centre = layer.transform.center();
QVERIFY2(std::abs(centre.x() - 6) <= 0.5 && std::abs(centre.y() - 5) <= 0.5, qPrintable(QString("%1, %2").arg(centre.x()).arg(centre.y())));
}
void PSBImportTests::aMergedImageIsJudgedBeforeTheFileIsRead()
{
// Sparse 75 MB planes: past a budget of one.
const QTemporaryDir folder;
for (const bool large : {false, true}) {
PSDFixture::Buffer header;
header.string("8BPS");
header.u16(large ? 2 : 1);
header.bytes(QByteArray(6, '\0'));
header.u16(3);
header.u32(5'000);
header.u32(5'000);
header.u16(8);
header.u16(3);
header.bytes(QByteArray(large ? 16 : 12, '\0'));
header.u16(0);
const QString path = written(folder, large ? "Big.psb" : "Big.psd", header.data);
QVERIFY(QFile(path).resize(header.data.size() + 75'000'000));
QVERIFY(PSDReader::read(path, 1).layers.empty());
std::optional<ImageImportError::Kind> kind;
{
const AddressSpaceLimit limit(32ll * 1024 * 1024);
try {
ImageImporter::decode(path, 1, true);
} catch (const ImageImportError &error) {
kind = error.kind;
}
}
QCOMPARE(kind, std::optional(ImageImportError::Kind::tooLarge));
}
}
void PSBImportTests::theReadingEndsBeforeTheMergedRead()
{
// Gone once the reading ends: Swift reads the merged after.
const QTemporaryDir folder;
const QString path = written(folder, "Gone.psd", PSDFixture::data(PSDDocument{5, 3, 72, {}}, PSDFixture::colorImage(5, 3, 1, 0, 0)));
EditorSession session;
session.createDocument(8, 8);
bool reading = false;
connect(&session, &EditorSession::changed, this, [&] {
reading = reading || session.showsConversionSheet();
if (reading && !session.showsConversionSheet() && QFile::exists(path))
QFile::remove(path);
});
bool done = false;
session.importImages({QUrl::fromLocalFile(path)}, std::nullopt, [&done] { done = true; });
QVERIFY(QTest::qWaitFor([&done] { return done; }, 20'000));
QVERIFY(reading && !QFile::exists(path));
QVERIFY(session.document().value().layers.empty());
QCOMPARE(session.importError(), std::optional("Gone.psd: " + QString(ImageImportError(ImageImportError::Kind::unreadable).what())));
}
void PSBImportTests::aLayeredReadSkipsTheProfile()
{
// A 64 MB profile, never copied while layers are read.
const QImage image = PSDFixture::colorImage(2, 2, 1, 0, 0);
for (const bool large : {false, true}) {
const QByteArray file = withProfile(PSDFixture::data(PSDDocument{2, 2, 72, {PSDFixture::record(QStringLiteral("Red"), image, QRectF(0, 0, 2, 2))}}, image, large),
QByteArray(64 * 1024 * 1024, 'x'));
std::optional<PSDDocument> read;
{
const AddressSpaceLimit limit(32ll * 1024 * 1024);
read = PSDReader::read(file);
}
QCOMPARE(read.value().layers.size(), size_t(1));
QCOMPARE(read.value().layers[0].image.value(), PSDReader::read(file).layers[0].image.value());
QCOMPARE(read.value().layers[0].image.value().pixel(1, 1), qRgb(255, 0, 0));
}
}
QTEST_MAIN(PSBImportTests)
#include "PSBImportTests.moc"
+60 -42
View File
@@ -20,6 +20,13 @@ struct Buffer {
u16(quint16(value));
}
void i32(qint32 value) { u32(quint32(value)); }
void u64(quint64 value)
{
u32(quint32(value >> 32));
u32(quint32(value));
}
// A Large Document's lengths are eight bytes.
void length(quint64 value, bool large) { large ? u64(value) : u32(quint32(value)); }
void bytes(const QByteArray &value) { data.append(value); }
void string(const char *value) { data.append(value); }
};
@@ -96,21 +103,21 @@ inline QByteArray packBits(const std::vector<uchar> &row)
}
// PackBits by row: the counts, then the rows.
inline QByteArray encode(const std::vector<uchar> &plane, int width, int height)
inline QByteArray encode(const std::vector<uchar> &plane, int width, int height, bool large = false)
{
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()));
large ? counts.u32(quint32(line.size())) : counts.u16(quint16(line.size()));
packed.append(line);
}
return counts.data + packed;
}
inline QByteArray channelPayload(const std::vector<uchar> &plane, int width, int height)
inline QByteArray channelPayload(const std::vector<uchar> &plane, int width, int height, bool large = false)
{
return QByteArray("\0\1", 2) + encode(plane, width, height);
return QByteArray("\0\1", 2) + encode(plane, width, height, large);
}
inline std::vector<Channel> emptyChannels()
@@ -119,7 +126,7 @@ inline std::vector<Channel> emptyChannels()
return {{-1, raw}, {0, raw}, {1, raw}, {2, raw}};
}
inline Prepared layer(const PSDRecord &record)
inline Prepared layer(const PSDRecord &record, bool large = false)
{
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()));
@@ -128,12 +135,12 @@ inline Prepared layer(const PSDRecord &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)});
prepared.channels.push_back({id, channelPayload(plane, width, height, large)});
} else {
prepared.channels = emptyChannels();
}
if (record.mask)
prepared.channels.push_back({-2, channelPayload(grayPlane(*record.mask), record.mask->width(), record.mask->height())});
prepared.channels.push_back({-2, channelPayload(grayPlane(*record.mask), record.mask->width(), record.mask->height(), large)});
prepared.top = top;
prepared.left = left;
prepared.bottom = top + height;
@@ -145,7 +152,7 @@ inline Prepared layer(const PSDRecord &record)
return prepared;
}
inline Prepared emptyLayer(const QString &name, const QString &blendKey, int section, std::optional<QUuid> parent, const PSDRecord *group = nullptr)
inline Prepared emptyLayer(const QString &name, const QString &blendKey, int section, std::optional<QUuid> parent, const PSDRecord *group, bool large)
{
PSDRecord record;
record.id = group ? group->id : QUuid::createUuid();
@@ -163,18 +170,25 @@ inline Prepared emptyLayer(const QString &name, const QString &blendKey, int sec
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.channels.push_back({-2, channelPayload(grayPlane(*record.mask), record.mask->width(), record.mask->height(), large)});
prepared.maskBottom = record.mask->height();
prepared.maskRight = record.mask->width();
}
return prepared;
}
inline void additional(Buffer &buffer, const char *key, const QByteArray &payload)
// Swift's AdditionalLayerInfo: one more block, before the name's.
struct AdditionalLayerInfo {
const char *key;
QByteArray payload;
};
inline void additional(Buffer &buffer, const char *key, const QByteArray &payload, bool large = false)
{
static const QStringList largeKeys{"LMsk", "Lr16", "Lr32", "Layr", "Mt16", "Mt32", "Mtrn", "Alph", "FMsk", "lnk2", "FEid", "FXid", "PxSD"};
buffer.string("8BIM");
buffer.string(key);
buffer.u32(quint32(payload.size()));
buffer.length(quint64(payload.size()), large && largeKeys.contains(QString::fromLatin1(key)));
buffer.bytes(payload);
if (payload.size() % 2 == 1)
buffer.u8(0);
@@ -189,7 +203,7 @@ inline QByteArray luni(const QString &name)
return data.data;
}
inline QByteArray extraData(const Prepared &item)
inline QByteArray extraData(const Prepared &item, bool large, const std::optional<AdditionalLayerInfo> &info)
{
Buffer extra;
if (item.record.mask && item.maskRight > item.maskLeft && item.maskBottom > item.maskTop) {
@@ -209,18 +223,20 @@ inline QByteArray extraData(const Prepared &item)
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 (info)
additional(extra, info->key, info->payload, large);
additional(extra, "luni", luni(item.record.name), large);
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);
additional(extra, "lsct", payload.data, large);
}
return extra.data;
}
inline void writeRecord(Buffer &buffer, const Prepared &item)
inline void writeRecord(Buffer &buffer, const Prepared &item, bool large, const std::optional<AdditionalLayerInfo> &info)
{
buffer.i32(item.top);
buffer.i32(item.left);
@@ -229,7 +245,7 @@ inline void writeRecord(Buffer &buffer, const Prepared &item)
buffer.u16(quint16(item.channels.size()));
for (const Channel &channel : item.channels) {
buffer.i16(channel.id);
buffer.u32(quint32(channel.payload.size()));
buffer.length(quint64(channel.payload.size()), large);
}
buffer.string("8BIM");
buffer.bytes((item.record.blendKey + QStringLiteral(" ")).left(4).toLatin1());
@@ -237,49 +253,50 @@ inline void writeRecord(Buffer &buffer, const Prepared &item)
buffer.u8(item.record.clipping ? 1 : 0);
buffer.u8(item.record.isVisible ? 0 : 2);
buffer.u8(0);
const QByteArray extra = extraData(item);
const QByteArray extra = extraData(item, large, info);
buffer.u32(quint32(extra.size()));
buffer.bytes(extra);
}
inline void emitLayers(const PSDDocument &document, std::optional<QUuid> parent, std::vector<Prepared> &prepared)
inline void emitLayers(const PSDDocument &document, std::optional<QUuid> parent, std::vector<Prepared> &prepared, bool large)
{
// 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));
prepared.push_back(emptyLayer(QStringLiteral("</Layer group>"), QStringLiteral("norm"), 3, parent, nullptr, large));
emitLayers(document, record.id, prepared, large);
prepared.push_back(emptyLayer(record.name, record.blendKey, 1, record.parentID, &record, large));
} else {
prepared.push_back(layer(record));
prepared.push_back(layer(record, large));
}
}
}
inline QByteArray layerSection(const PSDDocument &document)
inline QByteArray layerSection(const PSDDocument &document, bool large, const std::optional<AdditionalLayerInfo> &info)
{
std::vector<Prepared> prepared;
emitLayers(document, std::nullopt, prepared);
emitLayers(document, std::nullopt, prepared, large);
Buffer records, payloads;
records.i16(qint16(prepared.size()));
for (const Prepared &item : prepared) {
writeRecord(records, item);
writeRecord(records, item, large, info);
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 section;
section.length(0, large);
section.bytes(records.data);
section.bytes(payloads.data);
if (section.data.size() % 2 == 1)
section.u8(0);
const int field = large ? 8 : 4;
Buffer length;
length.u32(quint32(info.data.size() - 4));
info.data.replace(0, 4, length.data);
info.u32(0);
return info.data;
length.length(quint64(section.data.size() - field), large);
section.data.replace(0, field, length.data);
section.u32(0);
return section.data;
}
inline QByteArray resolutionResource(double resolution)
@@ -299,11 +316,11 @@ inline QByteArray resolutionResource(double resolution)
return resource.data;
}
inline QByteArray data(const PSDDocument &document, const QImage &composite)
inline QByteArray data(const PSDDocument &document, const QImage &composite, bool large = false, const std::optional<AdditionalLayerInfo> &info = std::nullopt)
{
Buffer file;
file.string("8BPS");
file.u16(1);
file.u16(large ? 2 : 1);
file.bytes(QByteArray(6, '\0'));
file.u16(4);
file.u32(quint32(document.height));
@@ -314,8 +331,8 @@ inline QByteArray data(const PSDDocument &document, const QImage &composite)
const QByteArray resources = resolutionResource(document.resolution);
file.u32(quint32(resources.size()));
file.bytes(resources);
const QByteArray layers = layerSection(document);
file.u32(quint32(layers.size()));
const QByteArray layers = layerSection(document, large, info);
file.length(quint64(layers.size()), large);
file.bytes(layers);
// The composite, PackBits by channel.
QImage flattened = BrushRaster::context(document.width, document.height, false);
@@ -326,9 +343,10 @@ inline QByteArray data(const PSDDocument &document, const QImage &composite)
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));
const QByteArray encoded = encode(*plane, document.width, document.height, large);
const int countBytes = document.height * (large ? 4 : 2);
counts.append(encoded.left(countBytes));
packed.append(encoded.mid(countBytes));
}
file.bytes(counts);
file.bytes(packed);
+2 -1
View File
@@ -86,8 +86,9 @@ void PSDReaderLayerTests::masksAndLayersSpendOneBudget()
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));
// Past the side, then still past three pixels once cut.
masked.mask = QRect(0, 0, 30'001, 1);
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(PSDReaderFixtures::file({masked})); }), std::optional(ImageImportError::Kind::tooLarge));
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(PSDReaderFixtures::file({masked}), 3); }), std::optional(ImageImportError::Kind::tooLarge));
}
void PSDReaderLayerTests::resourcesEndWhereTheyDeclare()
+4 -3
View File
@@ -192,7 +192,8 @@ 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));
// Cut to its canvas it fits: fifty pixels refuse it.
QCOMPARE(refusal<ImageImportError>([&] { PSDReader::read(oversized, 50); }), 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));
@@ -287,7 +288,7 @@ void PSDRoundTripTests::folderOpacityImportsOntoTheFolder()
void PSDRoundTripTests::unsupportedHeadersAreRejected()
{
QCOMPARE(refusal<PSDError>([] { PSDReader::read(header(2)); }), std::optional(PSDError::Kind::unsupportedVersion));
QCOMPARE(refusal<PSDError>([] { PSDReader::read(header(3)); }), 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));
@@ -297,7 +298,7 @@ void PSDRoundTripTests::unsupportedHeadersAreRejected()
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::unsupportedVersion).what()), QString("This Photoshop file uses a format version OmaPhoto can’t read."));
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."));
+2
View File
@@ -207,6 +207,8 @@ compositor_test(RemoveBackgroundTests)
compositor_test(SelectSubjectTests)
compositor_test(ObjectSelectionTests)
compositor_test(PSDRoundTripTests)
compositor_test(PSBImportTests)
compositor_test(CropToCanvasImportTests)
compositor_test(PSDVectorTests)
compositor_test(PSDReaderTests)
compositor_test(PSDReaderLayerTests)