Files
photog/desktop/imageprep.cpp
puttaalu d07e3eb0dc Add Qt6 desktop uploader
A native Qt6/C++ desktop client for the gallery's POST /upload API, in
desktop/ with its own flake (kept separate from the Go server module the
same way android/ is). Single-photo parity with the Android app:

- multipart upload with preemptive HTTP Basic auth and retry-aware outcome
  classification (uploadclient)
- JPEG/PNG metadata stripped byte-for-byte to drop embedded GPS, EXIF
  orientation baked in, and a quality/scale valve for the 20M nginx cap;
  HEIC decodes via the qtimageformats plugin (imageprep)
- base URL / username in QSettings, password in the system keychain via
  qtkeychain; https required and a trailing /upload stripped (settings)
- settings and post screens in a QStackedWidget (mainwindow)

Native Wayland or xcb is selected at runtime. Bulk upload, EXIF
capture-date prefill, and moving image prep off the UI thread are left as
follow-ups.
2026-08-26 00:28:35 +02:00

238 lines
7.6 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "imageprep.h"
#include <QBuffer>
#include <QDir>
#include <QFile>
#include <QImage>
#include <QImageReader>
#include <QImageWriter>
namespace {
// nginx is configured with client_max_body_size 20M; leave headroom.
constexpr qint64 kMaxUploadBytes = 18LL * 1024 * 1024;
constexpr int kJpegQuality = 92;
const int kQualityLadder[] = {92, 85, 75};
// Encodes to JPEG in memory at the given quality.
QByteArray encodeJpeg(const QImage &image, int quality)
{
QByteArray out;
QBuffer buffer(&out);
buffer.open(QIODevice::WriteOnly);
QImageWriter writer(&buffer, "jpeg");
writer.setQuality(quality);
writer.write(image);
return out;
}
// Decodes with EXIF orientation baked into the pixels. Works for HEIC too, via
// the qtimageformats plugin.
QImage decodeOriented(const QString &path)
{
QImageReader reader(path);
reader.setAutoTransform(true);
return reader.read();
}
} // namespace
ImagePrep::Prepared ImagePrep::prepare(const QString &sourcePath, const QString &cacheDir,
const QString &baseName)
{
Prepared result;
QFile in(sourcePath);
if (!in.open(QIODevice::ReadOnly)) {
result.error = QStringLiteral("Could not read the selected image");
return result;
}
const QByteArray source = in.readAll();
in.close();
QStringList notes;
// A rotation flag cannot survive metadata stripping, so bake it into the
// pixels instead, which costs a re-encode.
QImageReader orientationReader(sourcePath);
const bool mustRotate =
orientationReader.transformation() != QImageIOHandler::TransformationNone;
QByteArray bytes;
QString extension;
if (!mustRotate && isJpeg(source)) {
bytes = stripJpegSegments(source);
extension = QStringLiteral("jpg");
} else if (!mustRotate && isPng(source)) {
bytes = stripPngChunks(source);
extension = QStringLiteral("png");
}
if (bytes.isEmpty()) {
// Re-encode path: rotated originals, HEIC, or a JPEG/PNG whose marker
// structure did not parse.
const QImage image = decodeOriented(sourcePath);
if (image.isNull()) {
result.error = QStringLiteral("Could not decode the selected image");
return result;
}
if (mustRotate)
notes << QStringLiteral("rotated to match its EXIF orientation");
else if (!isJpeg(source) && !isPng(source))
notes << QStringLiteral("converted to JPEG");
bytes = encodeJpeg(image, kJpegQuality);
extension = QStringLiteral("jpg");
}
// Size valve. Only trips on genuinely huge originals; quality is stepped
// before touching resolution.
if (bytes.size() > kMaxUploadBytes) {
QImage image = decodeOriented(sourcePath);
if (image.isNull()) {
result.error = QStringLiteral("Could not decode the selected image");
return result;
}
QByteArray shrunk;
for (int quality : kQualityLadder) {
const QByteArray attempt = encodeJpeg(image, quality);
if (attempt.size() <= kMaxUploadBytes) {
shrunk = attempt;
notes << QStringLiteral("re-encoded at quality %1 to fit the 20M upload limit").arg(quality);
break;
}
}
if (shrunk.isEmpty()) {
double factor = 1.0;
while (factor > 0.2) {
factor *= 0.75;
const QImage scaled = image.scaledToWidth(
qMax(1, int(image.width() * factor)), Qt::SmoothTransformation);
const QByteArray attempt = encodeJpeg(scaled, kQualityLadder[2]);
if (attempt.size() <= kMaxUploadBytes) {
shrunk = attempt;
notes << QStringLiteral("scaled to %1×%2 to fit the 20M upload limit")
.arg(scaled.width()).arg(scaled.height());
break;
}
}
}
if (shrunk.isEmpty()) {
result.error = QStringLiteral("Image is too large to upload even after downscaling");
return result;
}
bytes = shrunk;
extension = QStringLiteral("jpg");
}
const QString outPath = QDir(cacheDir).filePath(baseName + QLatin1Char('.') + extension);
QFile out(outPath);
if (!out.open(QIODevice::WriteOnly) || out.write(bytes) != bytes.size()) {
result.error = QStringLiteral("Could not write the prepared image");
return result;
}
out.close();
result.filePath = outPath;
result.note = notes.join(QStringLiteral("; "));
return result;
}
bool ImagePrep::isJpeg(const QByteArray &b)
{
return b.size() > 3 && quint8(b[0]) == 0xFF && quint8(b[1]) == 0xD8 && quint8(b[2]) == 0xFF;
}
bool ImagePrep::isPng(const QByteArray &b)
{
return b.size() > 8 && quint8(b[0]) == 0x89 && quint8(b[1]) == 0x50 && quint8(b[2]) == 0x4E
&& quint8(b[3]) == 0x47;
}
// Drops APP1 (EXIF and XMP) and COM segments, keeping the compressed scan data
// byte-for-byte. APP0/JFIF and APP2/ICC stay: removing the colour profile would
// shift the rendered colours. Returns an empty array if the marker structure
// does not parse, so the caller falls back to a re-encode.
QByteArray ImagePrep::stripJpegSegments(const QByteArray &src)
{
const auto u8 = [&](int i) { return quint8(src[i]); };
QByteArray out;
out.append(char(0xFF));
out.append(char(0xD8));
int i = 2;
while (i + 1 < src.size()) {
if (u8(i) != 0xFF)
return {};
const int marker = u8(i + 1);
// Standalone markers: no length field, no payload.
if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) {
out.append(char(0xFF));
out.append(char(marker));
i += 2;
continue;
}
// Start of scan: entropy-coded data runs to the end of the file.
if (marker == 0xDA) {
out.append(src.mid(i));
return out;
}
if (i + 3 >= src.size())
return {};
const int length = (u8(i + 2) << 8) | u8(i + 3);
if (length < 2 || i + 2 + length > src.size())
return {};
const bool isExifOrXmp = marker == 0xE1;
const bool isComment = marker == 0xFE;
if (!isExifOrXmp && !isComment)
out.append(src.mid(i, 2 + length));
i += 2 + length;
}
return {};
}
// Drops the metadata chunks, keeps everything else including the critical
// IHDR/PLTE/IDAT/IEND run and colour chunks (iCCP, sRGB). Whole chunks go, so no
// CRC needs recomputing.
QByteArray ImagePrep::stripPngChunks(const QByteArray &src)
{
static const QByteArray drop[] = {"eXIf", "tEXt", "zTXt", "iTXt", "tIME"};
const auto u8 = [&](int i) { return quint8(src[i]); };
if (src.size() < 8)
return {};
QByteArray out;
out.append(src.mid(0, 8));
int i = 8;
while (i + 8 <= src.size()) {
const qint64 length =
(qint64(u8(i)) << 24) | (qint64(u8(i + 1)) << 16) | (qint64(u8(i + 2)) << 8) | qint64(u8(i + 3));
if (length < 0)
return {};
const QByteArray type = src.mid(i + 4, 4);
const qint64 total = 12 + length;
if (i + total > src.size())
return {};
bool dropped = false;
for (const QByteArray &d : drop) {
if (type == d) {
dropped = true;
break;
}
}
if (!dropped)
out.append(src.mid(i, int(total)));
i += int(total);
if (type == "IEND")
return out;
}
return {};
}