/* SPDX-FileCopyrightText: 2014 Alex Merry SPDX-FileCopyrightText: 2024 Mirco Miranda SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "fuzzyeq.cpp" QJsonObject readOptionalInfo(const QString &suffix) { auto fi = QFileInfo(QStringLiteral("%1/basic/%2.json").arg(IMAGEDIR, suffix)); if (!fi.exists()) { return {}; } QFile f(fi.filePath()); if (!f.open(QFile::ReadOnly)) { return {}; } QJsonParseError err; auto doc = QJsonDocument::fromJson(f.readAll(), &err); if (err.error != QJsonParseError::NoError || !doc.isObject()) { return {}; } return doc.object(); } void setOptionalInfo(QImage &image, const QString &suffix) { auto obj = readOptionalInfo(suffix); if (obj.isEmpty()) { return; } // Set resolution auto res = obj.value("resolution").toObject(); if (!res.isEmpty()) { image.setDotsPerMeterX(res.value("dotsPerMeterX").toInt()); image.setDotsPerMeterY(res.value("dotsPerMeterY").toInt()); } // Set metadata auto meta = obj.value("metadata").toArray(); for (auto jv : meta) { auto obj = jv.toObject(); auto key = obj.value("key").toString(); auto val = obj.value("value").toString(); image.setText(key, val); } } bool checkOptionalInfo(QImage &image, const QString &suffix) { auto obj = readOptionalInfo(suffix); if (obj.isEmpty()) { return true; } // Check if the format match if (suffix.compare(obj.value("format").toString(), Qt::CaseInsensitive)) { return false; } // Test resolution auto res = obj.value("resolution").toObject(); if (!res.isEmpty()) { auto resx = res.value("dotsPerMeterX").toInt(); auto resy = res.value("dotsPerMeterY").toInt(); if (resx != image.dotsPerMeterX()) { return false; } if (resy != image.dotsPerMeterY()) { return false; } } // Test metadata auto meta = obj.value("metadata").toArray(); for (auto jv : meta) { auto obj = jv.toObject(); auto key = obj.value("key").toString(); auto val = obj.value("value").toString(); auto cur = image.text(key); if (cur != val) { qDebug() << key; return false; } } return true; } /*! * \brief basicTest * Run a basic test on some common images. */ int basicTest(const QString &suffix, bool lossless, bool ignoreDataCheck, bool skipOptional, uint fuzzarg) { uchar fuzziness = uchar(fuzzarg); QByteArray format = suffix.toLatin1(); QDir imgdir(QStringLiteral("%1/basic").arg(IMAGEDIR)); if (ignoreDataCheck) { imgdir.setNameFilters({QLatin1String("*.png")}); } else { imgdir.setNameFilters(QStringList(QLatin1String("*.") + suffix)); } imgdir.setFilter(QDir::Files); int passed = 0; int failed = 0; QTextStream(stdout) << "********* " << "Starting BASIC write tests for " << suffix << " images *********\n"; const QFileInfoList lstImgDir = imgdir.entryInfoList(); for (const QFileInfo &fi : lstImgDir) { QString pngfile; if (ignoreDataCheck) { pngfile = fi.filePath(); } else { int suffixPos = fi.filePath().size() - suffix.size(); pngfile = fi.filePath().replace(suffixPos, suffix.size(), QStringLiteral("png")); } QString pngfilename = QFileInfo(pngfile).fileName(); QImageReader pngReader(pngfile, "png"); QImage pngImage; if (!pngReader.read(&pngImage)) { QTextStream(stdout) << "ERROR: " << fi.fileName() << ": could not load " << pngfilename << ": " << pngReader.errorString() << "\n"; ++failed; continue; } QByteArray writtenData; { QBuffer buffer(&writtenData); QImageWriter imgWriter(&buffer, format.constData()); if (lossless) { imgWriter.setQuality(100); } if (!skipOptional) { setOptionalInfo(pngImage, suffix); } if (!imgWriter.write(pngImage)) { QTextStream(stdout) << "FAIL : " << fi.fileName() << ": failed to write image data\n"; ++failed; continue; } } if (!ignoreDataCheck) { QFile expFile(fi.filePath()); if (!expFile.open(QIODevice::ReadOnly)) { QTextStream(stdout) << "ERROR: " << fi.fileName() << ": could not open " << fi.fileName() << ": " << expFile.errorString() << "\n"; ++failed; continue; } QByteArray expData = expFile.readAll(); if (expData.isEmpty()) { // check if there was actually anything to read expFile.reset(); char buf[1]; qint64 result = expFile.read(buf, 1); if (result < 0) { QTextStream(stdout) << "ERROR: " << fi.fileName() << ": could not load " << fi.fileName() << ": " << expFile.errorString() << "\n"; ++failed; continue; } } if (expData != writtenData) { QTextStream(stdout) << "FAIL : " << fi.fileName() << ": written data differs from " << fi.fileName() << "\n"; ++failed; continue; } } QImage reReadImage; { QBuffer buffer(&writtenData); QImageReader imgReader(&buffer, format.constData()); if (!imgReader.read(&reReadImage)) { QTextStream(stdout) << "FAIL : " << fi.fileName() << ": could not read back the written data\n"; ++failed; continue; } if (!skipOptional) { if (!checkOptionalInfo(reReadImage, suffix)) { QTextStream(stdout) << "FAIL : " << fi.fileName() << ": optional information does not match\n"; ++failed; continue; } } if (reReadImage.colorSpace().isValid()) { QColorSpace toColorSpace; if (pngImage.colorSpace().isValid()) { reReadImage.convertToColorSpace(pngImage.colorSpace()); } else { reReadImage.convertToColorSpace(QColorSpace(QColorSpace::SRgb)); } } reReadImage = reReadImage.convertToFormat(pngImage.format()); } if (lossless) { if (!fuzzyeq(pngImage, reReadImage, fuzziness)) { QTextStream(stdout) << "FAIL : " << fi.fileName() << ": re-reading the data resulted in a different image\n"; if (pngImage.size() == reReadImage.size()) { for (int i = 0; i < pngImage.width(); ++i) { for (int j = 0; j < pngImage.height(); ++j) { if (pngImage.pixel(i, j) != reReadImage.pixel(i, j)) { QTextStream(stdout) << "Pixel is different " << i << ',' << j << ' ' << pngImage.pixel(i, j) << ' ' << reReadImage.pixel(i, j) << '\n'; } } } } ++failed; continue; } } QTextStream(stdout) << "PASS : " << fi.fileName() << "\n"; ++passed; } QTextStream(stdout) << "Totals: " << passed << " passed, " << failed << " failed\n"; QTextStream(stdout) << "********* " << "Finished BASIC write tests for " << suffix << " images *********\n"; return failed == 0 ? 0 : 1; } QImage formatSourceImage(const QImage::Format &format) { auto image = QImage(); auto folder = QStringLiteral("%1/format/_images").arg(IMAGEDIR); switch (format) { case QImage::Format_MonoLSB: case QImage::Format_Mono: image = QImage(QStringLiteral("%1/mono.png").arg(folder)); break; case QImage::Format_Indexed8: image = QImage(QStringLiteral("%1/indexed.png").arg(folder)); break; case QImage::Format_Grayscale8: case QImage::Format_Grayscale16: image = QImage(QStringLiteral("%1/gray16.png").arg(folder)); break; case QImage::Format_RGB32: case QImage::Format_RGB16: case QImage::Format_RGB666: case QImage::Format_RGB555: case QImage::Format_RGB888: case QImage::Format_RGB444: case QImage::Format_RGBX8888: case QImage::Format_BGR30: case QImage::Format_RGB30: case QImage::Format_RGBX64: case QImage::Format_BGR888: case QImage::Format_RGBX16FPx4: case QImage::Format_RGBX32FPx4: image = QImage(QStringLiteral("%1/rgb16.png").arg(folder)); break; case QImage::Format_ARGB32: case QImage::Format_ARGB32_Premultiplied: case QImage::Format_ARGB8565_Premultiplied: case QImage::Format_ARGB6666_Premultiplied: case QImage::Format_ARGB8555_Premultiplied: case QImage::Format_ARGB4444_Premultiplied: case QImage::Format_RGBA8888: case QImage::Format_RGBA8888_Premultiplied: case QImage::Format_A2BGR30_Premultiplied: case QImage::Format_A2RGB30_Premultiplied: case QImage::Format_RGBA64: case QImage::Format_RGBA64_Premultiplied: case QImage::Format_RGBA16FPx4: case QImage::Format_RGBA16FPx4_Premultiplied: case QImage::Format_RGBA32FPx4: case QImage::Format_RGBA32FPx4_Premultiplied: image = QImage(QStringLiteral("%1/rgba16.png").arg(folder)); break; #if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) case QImage::Format_CMYK8888: image = QImage(QStringLiteral("%1/cmyk8.tif").arg(folder)); break; #endif default: break; } return image; } // enable to create the template images for the format test // #define FORMATTEST_CREATE_MODE /*! * \brief formatTest * Check if the plugin is able to write all QImage formats */ int formatTest(const QString &suffix, bool createTemplates) { auto formatFolder = QStringLiteral("%1/format/%2").arg(IMAGEDIR, suffix); int passed = 0; int failed = 0; int skipped = 0; QTextStream(stdout) << "********* " << "Starting FORMAT write tests for " << suffix << " images *********\n"; for (int i = QImage::Format_Invalid + 1; i < QImage::NImageFormats; ++i) { auto format = QImage::Format(i); auto formatName = QString(QMetaEnum::fromType().valueToKey(format)); // get template image auto srcImage = formatSourceImage(format); if (srcImage.isNull()) { ++skipped; QTextStream(stdout) << "SKIP : no source image found for " << formatName << "\n"; continue; } // convert source to the wanted format if (srcImage.format() != format) srcImage.convertTo(format); QByteArray ba; { // writing the image QBuffer buffer(&ba); QImageWriter writer(&buffer, suffix.toLatin1()); writer.setQuality(100); // always lossless if (!writer.write(srcImage)) { ++failed; QTextStream(stdout) << "FAIL : failed to write image " << formatName << "\n"; continue; } } // read the image QBuffer buffer(&ba); auto writtenImage = QImageReader(&buffer, suffix.toLatin1()).read(); if (writtenImage.isNull()) { if (suffix.toLatin1() == "heif") { // libheif + ffmpeg decoder is unable to load all HEIF files. ++skipped; } else { ++failed; } QTextStream(stdout) << "FAIL : error while reading the image " << formatName << "\n"; continue; } // read the template image auto templateName = QStringLiteral("%1/%2.%3").arg(formatFolder, formatName, suffix); if (createTemplates) { QDir().mkdir(formatFolder); QFile f(templateName); if (!f.open(QFile::WriteOnly) || f.write(ba) == -1) { ++failed; QTextStream(stdout) << "FAIL : error while creating template image " << formatName << "\n"; continue; } } if (!QFile::exists(templateName)) { ++skipped; QTextStream(stdout) << "SKIP : no template image found for " << formatName << "\n"; continue; } auto tmplImage = QImageReader(templateName, suffix.toLatin1()).read(); if (tmplImage.isNull()) { ++failed; QTextStream(stdout) << "FAIL : error while reading template image " << formatName << "\n"; continue; } // checking the format: must be the same if (writtenImage.format() != tmplImage.format()) { ++failed; auto writtenformatName = QString(QMetaEnum::fromType().valueToKey(writtenImage.format())); auto tmplformatName = QString(QMetaEnum::fromType().valueToKey(tmplImage.format())); QTextStream(stdout) << "FAIL : format mismatch " << formatName << " ("; if (format == writtenImage.format()) { QTextStream(stdout) << "template image: " << tmplformatName << ")\n"; } else if (format == tmplImage.format()) { QTextStream(stdout) << "written image: " << writtenformatName << ")\n"; } else { QTextStream(stdout) << writtenformatName << " != " << tmplformatName << ")\n"; } continue; } // This comparison is only to understand if the plugin has written a completely wrong image. I therefore have no // qualms about converting them to a more convenient format or to tolerate slightly different pixels. auto compareFormat = writtenImage.hasAlphaChannel() ? QImage::Format_ARGB32 : QImage::Format_RGB32; if (!fuzzyeq(writtenImage.convertToFormat(compareFormat), tmplImage.convertToFormat(compareFormat), 5)) { ++failed; QTextStream(stdout) << "FAIL : re-reading the data resulted in a different image " << formatName << "\n"; continue; } // test passed! QTextStream(stdout) << "PASS : " << formatName << "\n"; ++passed; } QTextStream(stdout) << "Totals: " << passed << " passed, " << failed << " failed, " << skipped << " skipped\n"; QTextStream(stdout) << "********* " << "Finished FORMAT write tests for " << suffix << " images *********\n"; return failed == 0 ? 0 : 1; } int sizeTest(const QString &suffix) { auto formatFolder = QStringLiteral("%1/format/%2").arg(IMAGEDIR, suffix); int passed = 0; int failed = 0; int skipped = 0; QTextStream(stdout) << "********* " << "Starting SIZE write tests for " << suffix << " images *********\n"; for (int i = QImage::Format_Invalid + 1; i < QImage::NImageFormats; ++i) { auto format = QImage::Format(i); auto formatName = QString(QMetaEnum::fromType().valueToKey(format)); // read template image auto templateImage = QImageReader(QStringLiteral("%1/%2.%3").arg(formatFolder, formatName, suffix)).read(); if (templateImage.isNull()) { ++skipped; QTextStream(stdout) << "SKIP : no source image found for " << formatName << "\n"; continue; } // clang-format off auto sizeList = QList() // check for byte alignment << (templateImage.size() + QSize(1, 1)) << (templateImage.size() + QSize(2, 2)) << (templateImage.size() + QSize(3, 3)) << (templateImage.size() + QSize(4, 4)) << (templateImage.size() + QSize(5, 5)) << (templateImage.size() + QSize(6, 6)) << (templateImage.size() + QSize(7, 7)) // Check for dividers (using prime numbers) << QSize(113, 157) << QSize(163, 109); // clang-format on for (auto &&sz : sizeList) { auto debugInfo = QStringLiteral("%1 @%2x%3").arg(formatName).arg(sz.width()).arg(sz.height()); // resize the template image auto resizeImage = templateImage.scaled(sz); // make sure the format is not changed by resize (paranoia) resizeImage.convertTo(templateImage.format()); // save test QByteArray ba; { // writing the image QBuffer buffer(&ba); QImageWriter writer(&buffer, suffix.toLatin1()); writer.setQuality(100); // always lossless if (!writer.write(resizeImage)) { ++failed; QTextStream(stdout) << "FAIL : failed to write image " << debugInfo << "\n"; continue; } } // read test QBuffer buffer(&ba); auto writtenImage = QImageReader(&buffer, suffix.toLatin1()).read(); if (writtenImage.isNull()) { ++failed; QTextStream(stdout) << "FAIL : error while reading the image " << debugInfo << "\n"; continue; } // This comparison is only to understand if the plugin has written a completely wrong image. I therefore have no // qualms about converting them to a more convenient format or to tolerate slightly different pixels. auto compareFormat = writtenImage.hasAlphaChannel() ? QImage::Format_ARGB32 : QImage::Format_RGB32; if (!fuzzyeq(writtenImage.convertToFormat(compareFormat), resizeImage.convertToFormat(compareFormat), 5)) { ++failed; QTextStream(stdout) << "FAIL : re-reading the data resulted in a different image " << debugInfo << "\n"; continue; } // test passed! QTextStream(stdout) << "PASS : " << debugInfo << "\n"; ++passed; } } QTextStream(stdout) << "Totals: " << passed << " passed, " << failed << " failed, " << skipped << " skipped\n"; QTextStream(stdout) << "********* " << "Finished SIZE write tests for " << suffix << " images *********\n"; return failed == 0 ? 0 : 1; } /*! * \brief nullDeviceTest * Checks the plugin behaviour when using a NULL device. */ int nullDeviceTest(const QString &suffix) { QTextStream(stdout) << "********* " << "Starting NULL device write tests for " << suffix << " images *********\n"; int passed = 0; int failed = 0; int skipped = 0; QImageWriter writer; writer.setFormat(suffix.toLatin1()); if (writer.canWrite()) { QTextStream(stdout) << "FAIL : canWrite() returns TRUE\n"; ++failed; } if (writer.write(QImage(16, 16, QImage::Format_ARGB32))) { QTextStream(stdout) << "FAIL : write() returns TRUE\n"; ++failed; } // test for crash only writer.compression(); writer.quality(); writer.transformation(); writer.subType(); writer.supportedSubTypes(); writer.optimizedWrite(); writer.progressiveScanWrite(); if (failed == 0) { // success ++passed; } QTextStream(stdout) << "Totals: " << passed << " passed, " << failed << " failed, " << skipped << " skipped\n"; QTextStream(stdout) << "********* " << "Finished format write tests for " << suffix << " images *********\n"; return failed == 0 ? 0 : 1; } int main(int argc, char **argv) { QCoreApplication app(argc, argv); QCoreApplication::removeLibraryPath(QStringLiteral(PLUGIN_DIR)); QCoreApplication::addLibraryPath(QStringLiteral(PLUGIN_DIR)); QCoreApplication::setApplicationName(QStringLiteral("writetest")); QCoreApplication::setApplicationVersion(QStringLiteral("2.0.0")); QCommandLineParser parser; parser.setApplicationDescription(QStringLiteral("Performs basic image conversion checking.")); parser.addHelpOption(); parser.addVersionOption(); parser.addPositionalArgument(QStringLiteral("format"), QStringLiteral("format to test.")); QCommandLineOption lossless(QStringList() << QStringLiteral("l") << QStringLiteral("lossless"), QStringLiteral("Check that reading back the data gives the same image.")); QCommandLineOption ignoreDataCheck({QStringLiteral("no-data-check")}, QStringLiteral("Don't check that write data is exactly the same.")); QCommandLineOption fuzz(QStringList() << QStringLiteral("f") << QStringLiteral("fuzz"), QStringLiteral("Allow for some deviation in ARGB data."), QStringLiteral("max")); QCommandLineOption createFormatTempates({QStringLiteral("create-format-templates")}, QStringLiteral("Create template images for all formats supported by QImage.")); QCommandLineOption skipOptTest({QStringLiteral("skip-optional-tests")}, QStringLiteral("Skip optional data tests (metadata, resolution, etc.).")); parser.addOption(lossless); parser.addOption(ignoreDataCheck); parser.addOption(fuzz); parser.addOption(createFormatTempates); parser.addOption(skipOptTest); parser.process(app); const QStringList args = parser.positionalArguments(); if (args.count() < 1) { QTextStream(stderr) << "Must provide a format\n"; parser.showHelp(1); } else if (args.count() > 1) { QTextStream(stderr) << "Too many arguments\n"; parser.showHelp(1); } uint fuzzarg = 0; if (parser.isSet(fuzz)) { bool ok; fuzzarg = parser.value(fuzz).toUInt(&ok); if (!ok || fuzzarg > 255) { QTextStream(stderr) << "Error: max fuzz argument must be a number between 0 and 255\n"; parser.showHelp(1); } } auto suffix = args.at(0); // skip test if libheif configuration is obviously incomplete QByteArray format = suffix.toLatin1(); const QList read_formats = QImageReader::supportedImageFormats(); const QList write_formats = QImageWriter::supportedImageFormats(); if (!read_formats.contains(format) && format == "heif") { QTextStream(stdout) << "WARNING : libheif configuration is missing necessary decoder(s)!\n"; return 0; } if (!write_formats.contains(format) && format == "heif") { QTextStream(stdout) << "WARNING : libheif configuration is missing necessary encoder(s)!\n"; return 0; } // run test auto ret = basicTest(suffix, parser.isSet(lossless), parser.isSet(ignoreDataCheck), parser.isSet(skipOptTest), fuzzarg); if (ret == 0) { ret = formatTest(suffix, parser.isSet(createFormatTempates)); } if (ret == 0) { ret = sizeTest(suffix); } if (ret == 0) { ret = nullDeviceTest(suffix); } return ret; }