diff --git a/.gitea/workflows/build_and_test.yml b/.gitea/workflows/build_and_test.yml index 900866d88..c0dcf6c2f 100644 --- a/.gitea/workflows/build_and_test.yml +++ b/.gitea/workflows/build_and_test.yml @@ -79,6 +79,18 @@ jobs: # still built because the viewer links the JFJochRugnux library for its in-process jobs. ninja -j16 cpack + - name: Verify one libjpeg in the viewer + shell: bash + run: | + set -euo pipefail + # With the static Qt, Qt's JPEG plugin brings its own libjpeg-turbo (another ABI) and the + # linker silently mixes it with ours (see viewer/CMakeLists.txt). Files, not pipes into + # `grep -q`, for the SIGPIPE reason given in build:rugnux-tgz. + strings build/viewer/jfjoch_viewer > /tmp/jfjv.strings + if grep -qE 'QJpegHandler|QTiffHandler' /tmp/jfjv.strings; then echo "a Qt JPEG/TIFF plugin is linked"; exit 1; fi + grep -c 'libjpeg-turbo version' /tmp/jfjv.strings > /tmp/jfjv.njpeg || true + if [ "$(cat /tmp/jfjv.njpeg)" != 1 ]; then echo "expected one libjpeg-turbo, found $(cat /tmp/jfjv.njpeg)"; exit 1; fi + echo "one libjpeg-turbo, no Qt JPEG/TIFF plugin" - name: Upload viewer tgz to release if: github.event_name == 'workflow_dispatch' && (github.event.inputs.create_release == 'true' || github.event.inputs.create_release == true) shell: bash diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 26eb487a0..0667e3c1c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,7 @@ ### 1.0.0-rc.174 +* jfjoch_viewer (Linux): processing no longer fails at the end with "Wrong JPEG library version" when writing the detector image. * Rugnux masks a patch of detector pixels that reads high on every frame, not only a single hot pixel or a pair. * Rugnux: `.hkl` now holds unmerged, scaled full reflections (SHELX HKLF 4) on rotation data, so SHELXL computes Rint itself. * Rugnux reads Rigaku d*TREK SMV images (Saturn CCD), including detector 2theta, image orientation and encoded pixel overflows. diff --git a/viewer/CMakeLists.txt b/viewer/CMakeLists.txt index b2c36913b..45666a744 100644 --- a/viewer/CMakeLists.txt +++ b/viewer/CMakeLists.txt @@ -219,6 +219,15 @@ ELSE() qt_import_plugins(jfjoch_viewer INCLUDE Qt::QXcbIntegrationPlugin) ENDIF() +# No Qt JPEG or TIFF image plugins: the viewer encodes JPEG and reads/writes TIFF through our own +# libjpeg-turbo and libtiff (preview/). With a static Qt the JPEG plugin brings Qt's bundled +# libjpeg-turbo, built for another ABI (JPEG_LIB_VERSION 80, ours is 62). Both export the same +# jpeg_* symbols, the linker silently keeps the first, and our encoder - the one rugnux writes +# _detector.jpg with - then fails with "Wrong JPEG library version". The TIFF plugin is unused and +# would bind to whichever libtiff the link happens to offer. A shared Qt never loads either plugin, +# as nothing goes through QImage for these formats. +qt_import_plugins(jfjoch_viewer EXCLUDE Qt::QJpegPlugin Qt::QTiffPlugin) + # Bundle the Qt runtime into the install tree at install time: this drives windeployqt on Windows # and macdeployqt on macOS, copying the Qt shared libraries + plugins next to the app. With a # static Qt (e.g. our Linux build) there is nothing to copy, so it is a no-op. Requires Qt >= 6.5, diff --git a/viewer/image_viewer/JFJochImage.cpp b/viewer/image_viewer/JFJochImage.cpp index e43d4f960..92175aaba 100644 --- a/viewer/image_viewer/JFJochImage.cpp +++ b/viewer/image_viewer/JFJochImage.cpp @@ -3,6 +3,7 @@ #include "JFJochImage.h" #include "../RemoteDisplayMode.h" +#include "../../preview/JFJochJPEG.h" #include #include @@ -14,11 +15,12 @@ #include #include #include -#include +#include #include #include #include #include +#include QRectF JFJochImageItem::boundingRect() const { return img_ ? QRectF(0, 0, img_->width(), img_->height()) : QRectF(); @@ -510,13 +512,23 @@ void JFJochImage::contextMenuEvent(QContextMenuEvent *event) { } } +// JPEG goes through our own libjpeg-turbo, not QImage::save - the viewer links no Qt JPEG plugin +// (see viewer/CMakeLists.txt). JPEG cannot store alpha, so it is dropped. Empty on failure. +static QByteArray encodeJPEG(const QImage &img, int quality) { + const QImage src = img.convertToFormat(QImage::Format_RGB888); + std::vector pixels(size_t(src.width()) * src.height()); + for (int y = 0; y < src.height(); y++) + memcpy(pixels.data() + size_t(y) * src.width(), src.constScanLine(y), size_t(src.width()) * sizeof(rgb)); + try { + const std::string jpeg = WriteJPEGToMem(CompressedImage(pixels, src.width(), src.height()), quality); + return {jpeg.data(), qsizetype(jpeg.size())}; + } catch (const std::exception &) { + return {}; + } +} + static void setClipboardAsJpegAndImage(const QImage &img, int quality = 95) { // Provide both "image/jpeg" and generic image flavors for better compatibility - QByteArray ba; - ba.reserve(img.width() * img.height() * 3 / 2); - QBuffer buf(&ba); - buf.open(QIODevice::WriteOnly); - QImage toSave = img; // Force 1:1 pixel ratio and standard DPI (96) to avoid scaling in consumer apps toSave.setDevicePixelRatio(1.0); @@ -525,10 +537,9 @@ static void setClipboardAsJpegAndImage(const QImage &img, int quality = 95) { toSave.setDotsPerMeterY(dotsPerMeter96DPI); toSave = toSave.convertToFormat(QImage::Format_ARGB32); // ensure a known format for encoding - toSave.save(&buf, "JPEG", quality); auto *mime = new QMimeData(); - mime->setData("image/jpeg", ba); + mime->setData("image/jpeg", encodeJPEG(toSave, quality)); mime->setImageData(toSave); // also set as generic bitmap QGuiApplication::clipboard()->setMimeData(mime); @@ -583,9 +594,9 @@ void JFJochImage::saveImageToFile(bool with_overlay) { if (!file_name.endsWith(".jpg", Qt::CaseInsensitive) && !file_name.endsWith(".jpeg", Qt::CaseInsensitive)) file_name += ".jpg"; - // JPEG cannot store alpha; flatten to RGB before encoding. - const QImage img = renderToImage(with_overlay).convertToFormat(QImage::Format_RGB32); - if (img.save(file_name, "JPEG", 95)) + const QByteArray jpeg = encodeJPEG(renderToImage(with_overlay), 95); + QFile file(file_name); + if (!jpeg.isEmpty() && file.open(QIODevice::WriteOnly) && file.write(jpeg) == jpeg.size()) emit writeStatusBar(tr("Saved %1").arg(file_name), 3000); else emit writeStatusBar(tr("Failed to save %1").arg(file_name), 3000);