#include "IO/PSD/PSDChannelCoder.h" #include "IO/PSD/PSDDocumentBuilder.h" #include "IO/PSD/PSDReader.h" #include "PSDReaderFixtures.h" #include #include // The reader's edges Swift's tests leave: bytes, names, flags, kinds. using PSDReaderFixtures::Record; namespace { template std::optional refusal(const std::function &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 messages(const PSDImport &imported) { std::vector 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{10, 20, 30, 30, 7, 7, 7, 7})); const auto truncated = [](const QByteArray &data) { return refusal([&] { 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{'a', 'b', 'c', 'd'})); QCOMPARE(refusal([] { PSDChannelCoder::decode(0, 2, 2, QByteArray("abc")); }), kind); QCOMPARE(refusal([] { 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(pixel, pixel + 4)), (std::vector{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{"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 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(mask.constScanLine(0), mask.constScanLine(0) + 3)), (std::vector{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{"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([&] { PSDReader::read(PSDReaderFixtures::file({wide})); }), std::optional(ImageImportError::Kind::tooLarge)); wide.extras[0].high = 1; QCOMPARE(refusal([&] { 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([] { PSDReader::read(PSDReaderFixtures::file({}, QByteArray(), 8, 8, 10'001)); }), std::optional(ImageImportError::Kind::tooLarge)); QCOMPARE(refusal([] { PSDReader::read(PSDReaderFixtures::file({}, QByteArray(), 8, 8, 10'000)); }), std::optional(PSDError::Kind::truncated)); QCOMPARE(refusal([] { 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{"Lone: Folder blend mode “mul ” isn’t supported. The folder will be pass-through."}); QCOMPARE(refusal([] { 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{ "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{"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{{0, 0}, {50, 0}, {200, 255}, {255, 255}})); QCOMPARE(curve.curves.channels[1], (std::vector{{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"