#include "IO/ImageExporter.h" #include "AddressSpaceLimit.h" #include "RenderFixtures.h" #include #include #include #include #include #include class JPEGExportTests : public QObject { Q_OBJECT private slots: void transparencyUsesChosenMatteAndProducesOpaqueSRGB_data(); void transparencyUsesChosenMatteAndProducesOpaqueSRGB(); void qualityChangesBytesAndDecodedPixels(); void thePreviewIsDecodedFromTheFileAndKeptSmall(); void pixelsLieOverTheMatteOnce(); void whatCannotBeEncodedDecodedOrAllocatedThrows(); void cancellationStopsAtSwiftsThreePoints(); void anEncodingWithoutRoomIsARenderError(); }; void JPEGExportTests::transparencyUsesChosenMatteAndProducesOpaqueSRGB_data() { QTest::addColumn("options"); QTest::newRow("the default white") << JPEGOptions{}; QTest::newRow("blue at full quality") << JPEGOptions{.quality = 1, .red = 0, .green = 0, .blue = 1}; QTest::newRow("a mixed matte") << JPEGOptions{.quality = 1, .red = 0.8, .green = 0.4, .blue = 0.2}; } void JPEGExportTests::transparencyUsesChosenMatteAndProducesOpaqueSRGB() { QFETCH(JPEGOptions, options); const ProjectSnapshot blank{.manifest = {.documentID = QUuid::createUuid(), .width = 20, .height = 12, .activeLayerID = std::nullopt, .layers = {}}, .images = {}}; const JPEGResult result = ImageExporter::jpeg(ImageExporter::render(blank), options); QVERIFY(result.data.startsWith("\xff\xd8\xff")); const QImage image = QImage::fromData(result.data, "jpeg"); QCOMPARE(image.size(), QSize(20, 12)); QCOMPARE(image.colorSpace(), QColorSpace(QColorSpace::SRgb)); QVERIFY(!image.hasAlphaChannel()); const QColor pixel = image.pixelColor(0, 0); QVERIFY(std::abs(pixel.redF() - options.red) < 0.03); QVERIFY(std::abs(pixel.greenF() - options.green) < 0.03); QVERIFY(std::abs(pixel.blueF() - options.blue) < 0.03); } void JPEGExportTests::qualityChangesBytesAndDecodedPixels() { QImage busy = BrushRaster::context(128, 128, false); for (int y = 0; y < 128; ++y) { for (int x = 0; x < 128; ++x) busy.setPixel(x, y, qRgba((x * 37 + y * 17) % 256, (x * 11 + y * 53) % 256, (x * 79 + y * 7) % 256, 255)); } const ExportRaster raster{busy}; const JPEGResult low = ImageExporter::jpeg(raster, {.quality = 0.1}), high = ImageExporter::jpeg(raster, {.quality = 1}); QVERIFY(low.data.size() < high.data.size()); // Quality is clamped to what the format knows. QCOMPARE(ImageExporter::jpeg(raster, {.quality = 7}).data, high.data); QCOMPARE(ImageExporter::jpeg(raster, {.quality = -3}).data, ImageExporter::jpeg(raster, {.quality = 0}).data); const QImage lowPixels = QImage::fromData(low.data), highPixels = QImage::fromData(high.data); double difference = 0; for (int y = 0; y < 16; ++y) { for (int x = 0; x < 16; ++x) difference += std::abs(lowPixels.pixelColor(x, y).redF() - highPixels.pixelColor(x, y).redF()); } QVERIFY(difference > 1); // Quality 1 is the writer's 100, 0.1 its 10. const auto byTheWriter = [&](int quality) { QImage opaque = busy.convertToFormat(QImage::Format_RGB888); opaque.setDotsPerMeterX(2835); opaque.setDotsPerMeterY(2835); opaque.setColorSpace(QColorSpace::SRgb); QByteArray data; QBuffer buffer(&data); QImageWriter writer(&buffer, "jpeg"); writer.setQuality(quality); writer.write(opaque); return data; }; QCOMPARE(high.data, byTheWriter(100)); QCOMPARE(low.data, byTheWriter(10)); QTemporaryDir directory; const QString path = directory.filePath("export.jpg"); ImageExporter::write(high.data, path); QFile written(path); QVERIFY(written.open(QIODevice::ReadOnly)); QCOMPARE(written.readAll(), high.data); } void JPEGExportTests::thePreviewIsDecodedFromTheFileAndKeptSmall() { const JPEGResult small = ImageExporter::jpeg({noise(64, 48, 1)}, {.quality = 0.1}); QCOMPARE(small.preview.size(), QSize(64, 48)); // Compression shows in the preview: it is not the source. QCOMPARE(small.preview.convertToFormat(QImage::Format_RGB888), QImage::fromData(small.data).convertToFormat(QImage::Format_RGB888)); QVERIFY(small.preview.convertToFormat(QImage::Format_RGB888) != noise(64, 48, 1).convertToFormat(QImage::Format_RGB888)); const JPEGResult wide = ImageExporter::jpeg({solid(2400, 600, qRgba(10, 200, 30, 255))}, {}); QCOMPARE(wide.preview.size(), QSize(1000, 250)); QCOMPARE(QImage::fromData(wide.data).size(), QSize(2400, 600)); const JPEGResult tall = ImageExporter::jpeg({solid(500, 2000, qRgba(10, 200, 30, 255))}, {}); QCOMPARE(tall.preview.size(), QSize(250, 1000)); QCOMPARE(ImageExporter::jpeg({solid(1000, 1000, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(1000, 1000)); // Thin images keep a pixel across. QCOMPARE(ImageExporter::jpeg({solid(2400, 1, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(1000, 1)); QCOMPARE(ImageExporter::jpeg({solid(1, 2400, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(1, 1000)); } void JPEGExportTests::pixelsLieOverTheMatteOnce() { // Half-transparent red over a blue matte. const JPEGResult result = ImageExporter::jpeg({solid(16, 16, qRgba(128, 0, 0, 128))}, {.quality = 1, .red = 0, .green = 0, .blue = 1}); const QColor pixel = QImage::fromData(result.data).pixelColor(8, 8); QVERIFY2(std::abs(pixel.red() - 128) <= 3 && pixel.green() <= 3 && std::abs(pixel.blue() - 127) <= 3, qPrintable(pixel.name())); } void JPEGExportTests::whatCannotBeEncodedDecodedOrAllocatedThrows() { const auto kind = [](const ExportRaster &raster) -> std::optional { try { ImageExporter::jpeg(raster, {}); } catch (const ExportError &error) { return error.kind; } return std::nullopt; }; // JPEG ends at 65,535 pixels a side. QImage endless(70'000, 1, QImage::Format_RGBA8888_Premultiplied); endless.fill(0); QCOMPARE(kind({endless}), std::optional(ExportError::Kind::encode)); // A preview the reader may not allocate. const int allowed = QImageReader::allocationLimit(); QImageReader::setAllocationLimit(1); const std::optional unread = kind({solid(1000, 1000, qRgba(1, 2, 3, 255))}); QImageReader::setAllocationLimit(allowed); QCOMPARE(unread, std::optional(ExportError::Kind::encode)); QCOMPARE(kind({solid(1000, 1000, qRgba(1, 2, 3, 255))}), std::nullopt); // No room for the flattened copy: 192 MB. const QImage large = solid(8192, 8192, qRgba(1, 2, 3, 255)); std::optional limit(std::in_place, 100 * 1024 * 1024); const std::optional unflattened = kind({large}); limit.reset(); QCOMPARE(unflattened, std::optional(ExportError::Kind::render)); } // The flatten fits; the growing file does not. void JPEGExportTests::anEncodingWithoutRoomIsARenderError() { const ExportRaster raster{noise(6000, 4000, 7)}; std::optional failed; { const AddressSpaceLimit limit(170 * 1024 * 1024); try { ImageExporter::jpeg(raster, {.quality = 1}); } catch (const ExportError &error) { failed = error.kind; } } QCOMPARE(failed, std::optional(ExportError::Kind::render)); } // Before the flatten, before the encoding, before the preview. void JPEGExportTests::cancellationStopsAtSwiftsThreePoints() { const ExportRaster raster{noise(64, 48, 1)}; int asked = 0; QCOMPARE(ImageExporter::jpeg(raster, {}, [&asked] { return ++asked < 0; }).data, ImageExporter::jpeg(raster, {}).data); QCOMPARE(asked, 3); for (int point = 1; point <= 3; ++point) { asked = 0; QVERIFY_THROWS_EXCEPTION(CancellationError, ImageExporter::jpeg(raster, {}, [&asked, point] { return ++asked == point; })); QCOMPARE(asked, point); } } QTEST_MAIN(JPEGExportTests) #include "JPEGExportTests.moc"