14.18: Export As for PNG, JPEG and PDF, sized, over a checkerboard

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 09:10:51 -04:00
1 parent e6b7f043bb
commit 9be01073df
26 files changed
+1352 -688

No files matched your search

+99 -1
View File
@@ -153,10 +153,43 @@ std::optional<QuickLookImages> ImageExporter::quickLookImages(const ProjectSnaps
QByteArray ImageExporter::pngData(const ProjectSnapshot &snapshot)
{
const ExportRaster raster = render(snapshot);
return pngData(render(snapshot));
}
QByteArray ImageExporter::pngData(const ExportRaster &raster)
{
return encode(raster.image, "png", -1, raster.resolution);
}
ExportRaster ImageExporter::resized(const ExportRaster &raster, int width, int height)
try {
if (width == raster.image.width() && height == raster.image.height())
return raster;
if (width < 1 || width > DocumentLimits::maxSide || height < 1 || height > DocumentLimits::maxSide
|| qint64(width) * height > DocumentLimits::maxSurfacePixels)
throw ExportError(ExportError::Kind::tooLarge);
// CoreGraphics' high quality: an area average shrinking.
const QImage image = raster.image.scaled(width, height, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (image.isNull())
throw ExportError(ExportError::Kind::render);
return {image, raster.resolution};
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
QImage ImageExporter::flattened(const ExportRaster &raster, const QColor &background)
try {
QImage flat(raster.image.size(), QImage::Format_RGB888);
if (flat.isNull())
throw ExportError(ExportError::Kind::render);
flat.fill(background);
QPainter painter(&flat);
painter.drawImage(QRectF(flat.rect()), raster.image);
return flat;
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
JPEGResult ImageExporter::jpeg(const ExportRaster &raster, const JPEGOptions &options, const std::function<bool()> &cancelled)
try {
const auto check = [&cancelled] {
@@ -192,6 +225,71 @@ try {
throw ExportError(ExportError::Kind::render);
}
QByteArray ImageExporter::pdfData(const ProjectSnapshot &snapshot)
{
return pdfData(render(snapshot));
}
// Swift's Core Graphics PDF context: one page, one Flate image.
QByteArray ImageExporter::pdfData(const ExportRaster &raster, std::optional<QColor> background)
try {
const QImage straight = raster.image.convertToFormat(QImage::Format_RGBA8888);
if (straight.isNull())
throw ExportError(ExportError::Kind::render);
const int width = straight.width(), height = straight.height();
QByteArray colour, alpha;
colour.reserve(qsizetype(width) * height * 3);
alpha.reserve(qsizetype(width) * height);
for (int y = 0; y < height; ++y) {
const uchar *row = straight.constScanLine(y);
for (int x = 0; x < width; ++x) {
colour.append(reinterpret_cast<const char *>(row + x * 4), 3);
alpha.append(char(row[x * 4 + 3]));
}
}
// Pixels at the document's resolution: points are 1/72 inch.
const double points = 72 / (raster.resolution > 0 ? raster.resolution : 72);
const auto number = [](double value) { return QByteArray::number(value, 'f', 4); };
const QByteArray pageWidth = number(width * points), pageHeight = number(height * points);
QByteArray content = "q\n";
if (background)
content += number(background->redF()) + ' ' + number(background->greenF()) + ' ' + number(background->blueF()) + " rg 0 0 " + pageWidth + ' '
+ pageHeight + " re f\n";
content += pageWidth + " 0 0 " + pageHeight + " 0 0 cm /Im0 Do\nQ";
// zlib's stream, without Qt's four-byte length.
const auto stream = [](const QByteArray &dictionary, const QByteArray &bytes, bool deflate) {
const QByteArray body = deflate ? qCompress(bytes, 9).mid(4) : bytes;
return "<< " + dictionary + (deflate ? " /Filter /FlateDecode" : "") + " /Length " + QByteArray::number(body.size()) + " >>\nstream\n" + body
+ "\nendstream";
};
const QByteArray image = "/Type /XObject /Subtype /Image /Width " + QByteArray::number(width) + " /Height " + QByteArray::number(height)
+ " /BitsPerComponent 8";
const std::vector<QByteArray> objects{"<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + pageWidth + ' ' + pageHeight
+ "] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >>",
stream("", content, false), stream(image + " /ColorSpace /DeviceRGB /SMask 6 0 R", colour, true),
stream(image + " /ColorSpace /DeviceGray", alpha, true)};
QByteArray pdf = "%PDF-1.4\n";
std::vector<qsizetype> offsets;
for (size_t index = 0; index < objects.size(); ++index) {
offsets.push_back(pdf.size());
pdf += QByteArray::number(qsizetype(index + 1)) + " 0 obj\n" + objects[index] + "\nendobj\n";
}
const qsizetype table = pdf.size();
pdf += "xref\n0 " + QByteArray::number(qsizetype(objects.size() + 1)) + "\n0000000000 65535 f \n";
for (const qsizetype offset : offsets)
pdf += QByteArray::number(offset).rightJustified(10, '0') + " 00000 n \n";
pdf += "trailer\n<< /Size " + QByteArray::number(qsizetype(objects.size() + 1)) + " /Root 1 0 R >>\nstartxref\n" + QByteArray::number(table) + "\n%%EOF\n";
return pdf;
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
void ImageExporter::exportPDF(const ProjectSnapshot &snapshot, const QString &path)
{
write(pdfData(snapshot), path);
}
void ImageExporter::exportPNG(const ProjectSnapshot &snapshot, const QString &path)
{
write(pngData(snapshot), path);
+10
View File
@@ -43,6 +43,16 @@ ExportRaster render(const ProjectSnapshot &snapshot);
// Flattened on white, 1024 a side; none past 50 megapixels.
std::optional<QuickLookImages> quickLookImages(const ProjectSnapshot &snapshot);
QByteArray pngData(const ProjectSnapshot &snapshot);
QByteArray pngData(const ExportRaster &raster);
// Export As's size: resampled smooth, the resolution kept.
ExportRaster resized(const ExportRaster &raster, int width, int height);
// The picture over a solid colour, as pages show it.
QImage flattened(const ExportRaster &raster, const QColor &background);
// One page the printed size, the pixels lossless, alpha kept.
QByteArray pdfData(const ProjectSnapshot &snapshot);
// `background` fills the page under the picture; none stays clear.
QByteArray pdfData(const ExportRaster &raster, std::optional<QColor> background = std::nullopt);
void exportPDF(const ProjectSnapshot &snapshot, const QString &path);
// Stops at Swift's three points once `cancelled` says so.
JPEGResult jpeg(const ExportRaster &raster, const JPEGOptions &options, const std::function<bool()> &cancelled = {});
void exportPNG(const ProjectSnapshot &snapshot, const QString &path);
+59 -34
View File
@@ -1,7 +1,7 @@
#include "Document/ProjectWorkspace.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectController.h"
#include "UI/JPEGExportSheet.h"
#include "UI/ExportAsSheet.h"
#include <QDialog>
#include <QFileDialog>
#include <QFileInfo>
@@ -15,18 +15,45 @@ QString suggested(const std::optional<QString> &project, const QString &suffix)
{
return (project ? ProjectTab::nameWithoutSuffix(*project) : QStringLiteral("Untitled")) + suffix;
}
// The panel's filter and a name's suffixes, preferred first.
std::pair<QString, QStringList> filtering(ExportFormat format)
{
switch (format) {
case ExportFormat::png:
return {QStringLiteral("PNG image (*.png)"), {QStringLiteral("png")}};
case ExportFormat::jpeg:
return {QStringLiteral("JPEG image (*.jpeg *.jpg *.jpe)"), {QStringLiteral("jpeg"), QStringLiteral("jpg"), QStringLiteral("jpe")}};
case ExportFormat::pdf:
return {QStringLiteral("PDF document (*.pdf)"), {QStringLiteral("pdf")}};
}
throw std::logic_error("unknown export format");
}
}
QString rawValue(ExportFormat format)
{
switch (format) {
case ExportFormat::png:
return QStringLiteral("PNG");
case ExportFormat::jpeg:
return QStringLiteral("JPEG");
case ExportFormat::pdf:
return QStringLiteral("PDF");
}
throw std::logic_error("unknown export format");
}
// Swift's NSSavePanel for one type, whose suffix a name needs.
void ProjectController::exportPanel(const QString &title, const QString &filter, const QStringList &suffixes, const QString &name, const QString &failure,
std::function<void(std::optional<QString>)> then)
void ProjectController::exportPanel(ExportFormat format, const QString &failure, std::function<void(std::optional<QString>)> then)
{
auto *panel = new QFileDialog(window, title);
const auto [filter, suffixes] = filtering(format);
auto *panel = new QFileDialog(window, QStringLiteral("Export ") + rawValue(format));
panel->setAttribute(Qt::WA_DeleteOnClose);
panel->setAcceptMode(QFileDialog::AcceptSave);
panel->setNameFilter(filter);
panel->setDefaultSuffix(suffixes.first());
panel->selectFile(name);
panel->selectFile(suggested(session.projectPath(), QLatin1Char('.') + suffixes.first()));
// Moved, never copied: `then` may hold a whole snapshot.
connect(panel, &QDialog::finished, this, [this, panel, suffixes, failure, then = std::move(then)](int result) mutable {
if (result != QDialog::Accepted) {
@@ -94,22 +121,21 @@ void ProjectController::exportPNG(std::function<void()> done)
showError(failure, QString::fromUtf8(ExportError(ExportError::Kind::render).what()), end);
return;
}
exportPanel(QStringLiteral("Export PNG"), QStringLiteral("PNG image (*.png)"), {QStringLiteral("png")}, suggested(session.projectPath(), QStringLiteral(".png")),
failure, [this, snapshot = std::move(snapshot.value()), failure, end](std::optional<QString> path) {
if (!path) {
end();
return;
}
// The snapshot's copy may find no memory.
try {
exportWork([snapshot, path = *path] { ImageExporter::exportPNG(snapshot, path); }, failure, end);
} catch (const std::bad_alloc &) {
showError(failure, QString::fromUtf8(ExportError(ExportError::Kind::render).what()), end);
}
});
exportPanel(ExportFormat::png, failure, [this, snapshot = std::move(snapshot.value()), failure, end](std::optional<QString> path) {
if (!path) {
end();
return;
}
// The snapshot's copy may find no memory.
try {
exportWork([snapshot, path = *path] { ImageExporter::exportPNG(snapshot, path); }, failure, end);
} catch (const std::bad_alloc &) {
showError(failure, QString::fromUtf8(ExportError(ExportError::Kind::render).what()), end);
}
});
}
void ProjectController::exportJPEG(std::function<void()> done)
void ProjectController::exportAs(std::optional<ExportFormat> start, std::function<void()> done)
{
if (!window || !session.document() || !begin()) {
if (done)
@@ -121,7 +147,7 @@ void ProjectController::exportJPEG(std::function<void()> done)
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
};
const QString failure = QStringLiteral("Couldn’t export JPEG");
const QString failure = QStringLiteral("Couldn’t export");
const QString render = QString::fromUtf8(ExportError(ExportError::Kind::render).what());
std::optional<ProjectSnapshot> snapshot;
try {
@@ -131,17 +157,17 @@ void ProjectController::exportJPEG(std::function<void()> done)
return;
}
auto *watcher = new QFutureWatcher<Rendered>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, failure, end] {
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, failure, end, format = start.value_or(m_lastExportFormat)] {
watcher->deleteLater();
Rendered rendered = watcher->future().takeResult();
if (!rendered.raster) {
showError(failure, rendered.failure, end);
return;
}
QDialog *dialog = sheet(QStringLiteral("Export JPEG"));
auto chosen = std::make_shared<std::optional<QByteArray>>();
dialog->layout()->addWidget(new JPEGExportSheet(std::move(*rendered.raster), session, [dialog, chosen](std::optional<QByteArray> data) {
*chosen = std::move(data);
QDialog *dialog = sheet(QStringLiteral("Export As"));
auto chosen = std::make_shared<std::optional<ExportAsSheet::Chosen>>();
dialog->layout()->addWidget(new ExportAsSheet(std::move(*rendered.raster), session, format, [dialog, chosen](std::optional<ExportAsSheet::Chosen> picked) {
*chosen = std::move(picked);
dialog->done(*chosen ? QDialog::Accepted : QDialog::Rejected);
}, dialog));
connect(dialog, &QDialog::finished, this, [this, chosen, failure, end] {
@@ -149,15 +175,14 @@ void ProjectController::exportJPEG(std::function<void()> done)
end();
return;
}
exportPanel(QStringLiteral("Export JPEG"), QStringLiteral("JPEG image (*.jpeg *.jpg *.jpe)"),
{QStringLiteral("jpeg"), QStringLiteral("jpg"), QStringLiteral("jpe")}, suggested(session.projectPath(), QStringLiteral(".jpg")), failure,
[this, data = **chosen, failure, end](std::optional<QString> path) {
if (!path) {
end();
return;
}
exportWork([data, path = *path] { ImageExporter::write(data, path); }, failure, end);
});
m_lastExportFormat = (*chosen)->format;
exportPanel((*chosen)->format, failure, [this, data = (*chosen)->data, failure, end](std::optional<QString> path) {
if (!path) {
end();
return;
}
exportWork([data, path = *path] { ImageExporter::write(data, path); }, failure, end);
});
});
dialog->open();
});
+13 -3
View File
@@ -6,6 +6,7 @@
#include <QPointer>
#include <QUrl>
#include <QWidget>
#include <array>
#include <deque>
#include <functional>
#include <optional>
@@ -13,6 +14,11 @@
class ProjectWorkspace;
class QDialog;
// What File › Export As… writes.
enum class ExportFormat { png, jpeg, pdf };
inline constexpr std::array allExportFormats{ExportFormat::png, ExportFormat::jpeg, ExportFormat::pdf};
QString rawValue(ExportFormat format);
// One project's file operations: save, open, close, drops.
class ProjectController : public QObject {
Q_OBJECT
@@ -44,8 +50,10 @@ public:
void gridSettings(std::function<void()> done = {});
void imageSize(std::function<void()> done = {});
void trim(std::function<void()> done = {});
// A quick, lossless save of the flattened canvas.
void exportPNG(std::function<void()> done = {});
void exportJPEG(std::function<void()> done = {});
// PNG, JPEG or PDF, sized and previewed before writing.
void exportAs(std::optional<ExportFormat> start = std::nullopt, std::function<void()> done = {});
// Swift's ProjectController+ExternalChanges: the package kept in step.
ExternalChangeState externalChanges;
@@ -94,8 +102,8 @@ private:
// Off the UI thread, then landed, skipped or explained.
void resizeProject(std::function<std::optional<ProjectSnapshot>(const ProjectSnapshot &)> resize, std::function<void(const ProjectSnapshot &)> land,
const QString &failure, std::function<void()> done);
void exportPanel(const QString &title, const QString &filter, const QStringList &suffixes, const QString &name, const QString &failure,
std::function<void(std::optional<QString>)> then);
// Swift's savePanel: the project's name, the format's suffix.
void exportPanel(ExportFormat format, const QString &failure, std::function<void(std::optional<QString>)> then);
void exportWork(std::function<void()> work, const QString &failure, std::function<void()> end);
void drainIncoming();
void handle(const Incoming &request);
@@ -113,4 +121,6 @@ private:
std::vector<std::function<void()>> m_writeWaiters;
std::deque<Incoming> m_incoming;
bool m_processing = false;
// The format Export As last wrote, offered first next time.
ExportFormat m_lastExportFormat = ExportFormat::png;
};
+4 -1
View File
@@ -40,7 +40,9 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, Pro
file->addSeparator();
add(file, QStringLiteral("exportPNG"), QStringLiteral("Export PNG…"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_E), [this] { projects().exportPNG(); });
add(file, QStringLiteral("exportJPEG"), QStringLiteral("Export JPEG…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::SHIFT | Qt::Key_S),
[this] { projects().exportJPEG(); });
[this] { projects().exportAs(ExportFormat::jpeg); });
// Swift 1.4.8: Photoshop's Ctrl+Alt+Shift+W.
add(file, QStringLiteral("exportAs"), QStringLiteral("Export As…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::SHIFT | Qt::Key_W), [this] { projects().exportAs(); });
file->addSeparator();
add(file, QStringLiteral("closeProject"), QStringLiteral("Close Project"), QKeySequence(Qt::CTRL | Qt::Key_W), [this] { projects().close(&m_window); });
@@ -356,6 +358,7 @@ void CompositorMenus::synchronize()
action(QStringLiteral("saveAs"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("exportPNG"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("exportJPEG"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("exportAs"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("closeProject"))->setEnabled(p.canStart());
// A step's ampersand, doubled: a lone one is a mnemonic.
const auto shown = [](QString name) { return name.replace(QLatin1Char('&'), QStringLiteral("&&")); };
@@ -1,10 +1,22 @@
#include "UI/JPEGExportSheet.h"
#include "UI/ExportAsSheet.h"
#include <QMouseEvent>
#include <QPainter>
#include <QScrollBar>
#include <algorithm>
#include <cmath>
// Swift's tile: 10-point squares of grays 0.30 and 0.35.
QPixmap JPEGPreview::checkerboard(double ratio)
{
QPixmap tile(QSize(20, 20) * ratio);
tile.setDevicePixelRatio(ratio);
QPainter painter(&tile);
painter.fillRect(QRectF(0, 0, 20, 20), QColor::fromRgbF(0.30f, 0.30f, 0.30f));
painter.fillRect(QRectF(0, 0, 10, 10), QColor::fromRgbF(0.35f, 0.35f, 0.35f));
painter.fillRect(QRectF(10, 10, 10, 10), QColor::fromRgbF(0.35f, 0.35f, 0.35f));
return tile;
}
double JPEGPreview::fitZoom(int width, int height, QSizeF frame, double displayScale)
{
const double scale = std::max(1.0, displayScale);
@@ -21,8 +33,8 @@ std::optional<double> JPEGPreview::step(double zoom, int direction)
return next == steps.rend() ? std::nullopt : std::optional(*next);
}
JPEGPreview::JPEGPreview(QSize pixels, std::function<void()> zoomChanged, QWidget *parent)
: QAbstractScrollArea(parent), m_pixels(pixels), m_zoomChanged(std::move(zoomChanged))
JPEGPreview::JPEGPreview(std::function<void()> zoomChanged, QWidget *parent)
: QAbstractScrollArea(parent), m_zoomChanged(std::move(zoomChanged))
{
setFixedSize(frame);
setFrameShape(QFrame::NoFrame);
@@ -31,13 +43,17 @@ JPEGPreview::JPEGPreview(QSize pixels, std::function<void()> zoomChanged, QWidge
verticalScrollBar()->setSingleStep(20);
}
void JPEGPreview::setImage(const QImage &image, bool updating)
void JPEGPreview::setImage(const QImage &image, QSize pixels, bool updating)
{
// A new image only: the averaged copy still holds.
if (image.cacheKey() != m_image.cacheKey())
m_reduced = QImage();
m_image = image;
m_updating = updating;
if (pixels != m_pixels) {
m_pixels = pixels;
updateRanges();
}
viewport()->update();
}
@@ -118,6 +134,11 @@ void JPEGPreview::paintEvent(QPaintEvent *)
target = QRectF(QPointF(), QSizeF(m_image.size()).scaled(view.size(), Qt::KeepAspectRatio));
target.moveCenter(view.center());
}
// Swift 1.4.8: the canvas's checkerboard shows what is clear.
painter.save();
painter.setBrushOrigin(target.topLeft());
painter.fillRect(target, QBrush(checkerboard(devicePixelRatioF())));
painter.restore();
// Nearest from 100% up: each JPEG pixel as it is.
if (m_zoom && *m_zoom >= 1) {
painter.drawImage(target, m_image);
+431
View File
@@ -0,0 +1,431 @@
#include "UI/ExportAsSheet.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/ByteCounts.h"
#include "UI/ColorPickerSheet+Dialog.h"
#include "UI/LayerIcons.h"
#include "Document/DocumentLimits.h"
#include "UI/ColorPickerSheet.h"
#include <QButtonGroup>
#include <QFutureWatcher>
#include <QGridLayout>
#include <QMenu>
#include <QToolButton>
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QProgressBar>
#include <QPushButton>
#include <QSettings>
#include <QShortcut>
#include <QSlider>
#include <QTimer>
#include <QVBoxLayout>
#include <QtConcurrent>
#include <algorithm>
#include <cmath>
const QString ExportAsSheet::qualityKey = QStringLiteral("jpegExportQuality");
namespace {
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent)
{
auto *label = new QLabel(words, parent);
label->setTextFormat(Qt::PlainText);
QFont font = label->font();
font.setPixelSize(pixels);
font.setWeight(weight);
label->setFont(font);
label->setForegroundRole(role);
return label;
}
// Swift reads a finite number alone, clamped to 0–1.
double startQuality()
{
bool number = false;
const double saved = QSettings().value(ExportAsSheet::qualityKey).toDouble(&number);
return number && std::isfinite(saved) ? std::clamp(saved, 0.0, 1.0) : JPEGOptions().quality;
}
// Swift's 0...2 decimals, trailing zeros dropped.
QString inches(double value)
{
QString shown = QLocale(QLocale::English, QLocale::UnitedStates).toString(value, 'f', 2);
while (shown.contains(QLatin1Char('.')) && (shown.endsWith(QLatin1Char('0')) || shown.endsWith(QLatin1Char('.'))))
shown.chop(1);
return shown;
}
struct Made {
std::optional<QByteArray> data;
QImage preview;
std::optional<QString> failure;
};
}
ExportAsSheet::ExportAsSheet(ExportRaster raster, EditorSession &session, ExportFormat format, std::function<void(std::optional<Chosen>)> finish, QWidget *parent)
: QWidget(parent), m_raster(std::move(raster)), m_session(session), m_finish(std::move(finish)),
m_settings{format, JPEGOptions(), m_raster.image.width(), m_raster.image.height()}, m_wait(new QTimer(this)),
m_preview(new JPEGPreview([this] { synchronize(); }, this)), m_spinner(new QProgressBar(m_preview)), m_formats(new QButtonGroup(this)),
m_width(new PickerField([this] {
bool number = false;
const int value = m_width->text().toInt(&number);
if (number)
setScale(double(value) / m_raster.image.width());
// A number applies; other text goes back.
showSize();
synchronize();
}, [](int) {}, this)),
m_height(new PickerField([this] {
bool number = false;
const int value = m_height->text().toInt(&number);
if (number)
setScale(double(value) / m_raster.image.height());
// A number applies; other text goes back.
showSize();
synchronize();
}, [](int) {}, this)),
m_scale(new QToolButton(this)), m_quality(new QSlider(Qt::Horizontal, this)), m_percent(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)),
m_page(text(QString(), 13, QFont::Normal, QPalette::PlaceholderText, this)),
m_matteSwatch(new DialogColorSwatch(
QStringLiteral("Export Background"), [this] { return PaletteColor{m_settings.options.red, m_settings.options.green, m_settings.options.blue}; },
[this](const PaletteColor &matte) { setMatte(matte); }, session, this)),
m_note(text(QString(), 13, QFont::Normal, QPalette::PlaceholderText, this)), m_failure(text(QString(), 13, QFont::Normal, QPalette::BrightText, this)),
m_bytes(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)),
m_updating(text(QStringLiteral("Updating…"), 13, QFont::Normal, QPalette::PlaceholderText, this)),
m_export(new QPushButton(QStringLiteral("Export…"), this)), m_fit(new QPushButton(QStringLiteral("Fit"), this)), m_zoomIn(new QPushButton(this)),
m_zoomOut(new QPushButton(this))
{
m_settings.options.quality = startQuality();
auto *column = new QVBoxLayout(this);
column->setContentsMargins(24, 24, 24, 24);
column->setSpacing(16);
// The title row sits 8 above; it inherits 8.
auto *top = new QVBoxLayout;
top->setSpacing(8);
auto *title = new QHBoxLayout;
title->addWidget(text(QStringLiteral("Export As"), 17, QFont::Bold, QPalette::WindowText, this));
title->addStretch(1);
m_fit->setObjectName(QStringLiteral("exportFit"));
m_zoomIn->setObjectName(QStringLiteral("exportZoomIn"));
m_zoomIn->setAccessibleName(QStringLiteral("Zoom in"));
m_zoomOut->setObjectName(QStringLiteral("exportZoomOut"));
m_zoomOut->setAccessibleName(QStringLiteral("Zoom out"));
for (QPushButton *button : {m_fit, m_zoomIn, m_zoomOut}) {
button->setAutoDefault(false);
title->addWidget(button);
}
connect(m_fit, &QPushButton::clicked, this, [this] { m_preview->setZoom(std::nullopt); });
connect(m_zoomIn, &QPushButton::clicked, this, [this] { zoomBy(1); });
connect(m_zoomOut, &QPushButton::clicked, this, [this] { zoomBy(-1); });
top->addLayout(title);
m_preview->setObjectName(QStringLiteral("exportPreview"));
m_spinner->setObjectName(QStringLiteral("exportSpinner"));
m_spinner->setRange(0, 0);
m_spinner->setTextVisible(false);
m_spinner->setGeometry(264, 149, 32, 32);
top->addWidget(m_preview);
column->addLayout(top);
auto *grid = new QGridLayout;
grid->setHorizontalSpacing(12);
grid->setVerticalSpacing(12);
// Swift's segmented picker: touching buttons, one checked.
auto *formats = new QHBoxLayout;
formats->setSpacing(0);
for (const ExportFormat each : allExportFormats) {
auto *button = new QToolButton(this);
button->setText(rawValue(each));
button->setObjectName(QStringLiteral("exportFormat") + rawValue(each));
button->setCheckable(true);
button->setChecked(each == format);
m_formats->addButton(button, int(each));
formats->addWidget(button);
}
formats->addStretch(1);
connect(m_formats, &QButtonGroup::idClicked, this, [this](int id) {
m_settings.format = ExportFormat(id);
request();
});
grid->addWidget(new QLabel(QStringLiteral("Format"), this), 0, 0);
grid->addLayout(formats, 0, 1);
auto *size = new QHBoxLayout;
size->setSpacing(6);
m_width->setObjectName(QStringLiteral("exportWidth"));
m_width->setToolTip(QStringLiteral("Width in pixels; the height follows, keeping the proportions"));
m_height->setObjectName(QStringLiteral("exportHeight"));
m_height->setToolTip(QStringLiteral("Height in pixels; the width follows, keeping the proportions"));
for (PickerField *field : {m_width, m_height}) {
field->setFixedWidth(64);
field->setAlignment(Qt::AlignRight);
}
size->addWidget(m_width);
size->addWidget(text(QStringLiteral("×"), 13, QFont::Normal, QPalette::PlaceholderText, this));
size->addWidget(m_height);
size->addWidget(text(QStringLiteral("px"), 13, QFont::Normal, QPalette::PlaceholderText, this));
m_scale->setObjectName(QStringLiteral("exportScale"));
m_scale->setToolTip(QStringLiteral("Scale the export: the document stays as it is"));
m_scale->setPopupMode(QToolButton::InstantPopup);
auto *scales = new QMenu(m_scale);
for (const int percent : {25, 50, 75, 100, 200})
connect(scales->addAction(QStringLiteral("%1%").arg(percent)), &QAction::triggered, this, [this, percent] {
setScale(percent / 100.0);
// The sheet's own change replaces any typing.
showSize();
synchronize();
});
m_scale->setMenu(scales);
size->addWidget(m_scale);
size->addStretch(1);
grid->addWidget(new QLabel(QStringLiteral("Size"), this), 1, 0);
grid->addLayout(size, 1, 1);
// Swift's slider steps by hundredths.
auto *qualityLabel = new QLabel(QStringLiteral("Quality"), this);
qualityLabel->setBuddy(m_quality);
m_quality->setObjectName(QStringLiteral("exportQuality"));
m_quality->setRange(0, 100);
m_quality->setFixedWidth(300);
m_quality->setValue(int(std::lround(m_settings.options.quality * 100)));
connect(m_quality, &QSlider::valueChanged, this, [this](int hundredths) {
m_settings.options.quality = hundredths / 100.0;
request();
});
m_percent->setObjectName(QStringLiteral("exportPercent"));
m_percent->setFixedWidth(45);
m_percent->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
auto *quality = new QWidget(this);
auto *qualityRow = new QHBoxLayout(quality);
qualityRow->setContentsMargins(0, 0, 0, 0);
qualityRow->addWidget(m_quality);
qualityRow->addWidget(m_percent);
qualityRow->addStretch(1);
grid->addWidget(qualityLabel, 2, 0);
grid->addWidget(quality, 2, 1);
m_jpegRow = {qualityLabel, quality};
auto *pageLabel = new QLabel(QStringLiteral("Page"), this);
m_page->setObjectName(QStringLiteral("exportPage"));
grid->addWidget(pageLabel, 3, 0);
grid->addWidget(m_page, 3, 1);
m_pdfRow = {pageLabel, m_page};
// JPEG and a page both need a colour beneath.
auto *backgroundLabel = new QLabel(QStringLiteral("Background"), this);
m_matteSwatch->setObjectName(QStringLiteral("exportBackground"));
m_matteSwatch->setToolTip(QStringLiteral("Color that fills transparent areas"));
grid->addWidget(backgroundLabel, 4, 0);
grid->addWidget(m_matteSwatch, 4, 1, Qt::AlignLeft);
m_backgroundRow = {backgroundLabel, m_matteSwatch};
column->addLayout(grid);
auto *buttons = new QHBoxLayout;
buttons->setSpacing(12);
m_note->setObjectName(QStringLiteral("exportNote"));
buttons->addWidget(m_note);
buttons->addStretch(1);
m_failure->setObjectName(QStringLiteral("exportError"));
m_bytes->setObjectName(QStringLiteral("exportBytes"));
m_updating->setObjectName(QStringLiteral("exportUpdating"));
buttons->addWidget(m_failure);
buttons->addWidget(m_bytes);
buttons->addWidget(m_updating);
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
cancel->setObjectName(QStringLiteral("exportCancel"));
cancel->setAutoDefault(false);
connect(cancel, &QPushButton::clicked, this, [this] {
DialogColorSwatch::closePicker(m_session);
m_finish(std::nullopt);
});
m_export->setObjectName(QStringLiteral("exportExport"));
m_export->setDefault(true);
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, m_export, cancel);
// Export rests until a result is ready.
connect(m_export, &QPushButton::clicked, this, [this] {
DialogColorSwatch::closePicker(m_session);
if (m_settings.format == ExportFormat::jpeg)
QSettings().setValue(qualityKey, m_settings.options.quality);
m_finish(Chosen{m_result.value().data, m_result.value().settings.format});
});
buttons->addWidget(cancel);
buttons->addWidget(m_export);
column->addLayout(buttons);
m_wait->setObjectName(QStringLiteral("exportWait"));
m_wait->setSingleShot(true);
m_wait->setInterval(200);
m_wait->setTimerType(Qt::PreciseTimer);
connect(m_wait, &QTimer::timeout, this, &ExportAsSheet::encode);
bindZoomKeys();
connect(&ShortcutSettings::shared(), &ShortcutSettings::changed, this, &ExportAsSheet::bindZoomKeys);
request();
}
// A window-modal sheet blocks the menus: it takes their keys.
void ExportAsSheet::bindZoomKeys()
{
const std::array<QKeySequence, 4> originals{QKeySequence(Qt::CTRL | Qt::Key_0), QKeySequence(Qt::CTRL | Qt::Key_1),
QKeySequence(Qt::CTRL | Qt::Key_Equal), QKeySequence(Qt::CTRL | Qt::Key_Minus)};
const std::array<std::function<void()>, 4> actions{[this] { m_preview->setZoom(std::nullopt); }, [this] { m_preview->setZoom(1.0); },
[this] { zoomBy(1); }, [this] { zoomBy(-1); }};
for (size_t index = 0; index < m_zoomKeys.size(); ++index) {
if (!m_zoomKeys[index]) {
m_zoomKeys[index] = new QShortcut(this);
connect(m_zoomKeys[index], &QShortcut::activated, this, actions[index]);
}
m_zoomKeys[index]->setKey(ShortcutSettings::shared().menu(originals[index]));
}
synchronize();
}
// SwiftUI cancels the task when the sheet goes.
ExportAsSheet::~ExportAsSheet()
{
m_cancelled->store(true);
releaseFocus(*this);
DialogColorSwatch::closePicker(m_session);
}
// Swift's task(id:): the last request stops; this waits 200 ms.
void ExportAsSheet::request()
{
if (m_cancelled)
m_cancelled->store(true);
m_cancelled = std::make_shared<std::atomic_bool>(false);
m_error.reset();
m_wait->start();
synchronize();
}
void ExportAsSheet::encode()
{
const std::shared_ptr<std::atomic_bool> cancelled = m_cancelled;
const Settings requested = m_settings;
auto *watcher = new QFutureWatcher<Made>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, cancelled, requested] {
watcher->deleteLater();
// Swift's check after the await: a newer request supersedes.
if (cancelled->load())
return;
Made made = watcher->future().takeResult();
if (made.data)
m_result = Encoded{requested, std::move(*made.data), std::move(made.preview)};
else
m_error = made.failure.value();
synchronize();
});
watcher->setFuture(QtConcurrent::run([raster = m_raster, requested, cancelled]() -> Made {
try {
const ExportRaster sized = ImageExporter::resized(raster, requested.width, requested.height);
const JPEGOptions &options = requested.options;
const QColor background = QColor::fromRgbF(float(options.red), float(options.green), float(options.blue));
switch (requested.format) {
case ExportFormat::jpeg: {
JPEGResult encoded = ImageExporter::jpeg(sized, options, [cancelled] { return cancelled->load(); });
return {std::move(encoded.data), std::move(encoded.preview), std::nullopt};
}
case ExportFormat::png:
return {ImageExporter::pngData(sized), sized.image, std::nullopt};
case ExportFormat::pdf:
return {ImageExporter::pdfData(sized, background), ImageExporter::flattened(sized, background), std::nullopt};
}
throw std::logic_error("unknown export format");
} catch (const CancellationError &) {
return {};
} catch (const ExportError &error) {
return {std::nullopt, QImage(), QString::fromUtf8(error.what())};
}
}));
}
void ExportAsSheet::setMatte(const PaletteColor &matte)
{
JPEGOptions next = m_settings.options;
next.red = matte.red;
next.green = matte.green;
next.blue = matte.blue;
// Equal settings ask nothing, as Swift's task id.
if (next == m_settings.options)
return;
m_settings.options = next;
request();
}
void ExportAsSheet::setScale(double scale)
{
const double width = m_raster.image.width(), height = m_raster.image.height();
const double largest = std::min(DocumentLimits::maxSide / std::max(width, height), std::sqrt(double(DocumentLimits::maxSurfacePixels) / (width * height)));
const double clamped = std::min(largest, std::max(0.01, std::isfinite(scale) ? scale : 1.0));
const Settings before = m_settings;
m_settings.width = std::max(1, int(std::lround(width * clamped)));
m_settings.height = std::max(1, int(std::lround(height * clamped)));
if (m_settings != before)
request();
}
void ExportAsSheet::showSize()
{
m_width->setText(QString::number(m_settings.width));
m_height->setText(QString::number(m_settings.height));
}
double ExportAsSheet::shownZoom() const
{
return m_preview->zoom().value_or(JPEGPreview::fitZoom(m_settings.width, m_settings.height, QSizeF(JPEGPreview::frame), devicePixelRatioF()));
}
void ExportAsSheet::zoomBy(int direction)
{
if (const std::optional<double> next = JPEGPreview::step(shownZoom(), direction))
m_preview->setZoom(next);
}
void ExportAsSheet::changeEvent(QEvent *event)
{
QWidget::changeEvent(event);
if (event->type() == QEvent::PaletteChange)
synchronize();
}
void ExportAsSheet::synchronize()
{
const bool ready = m_result && m_result->settings == m_settings && !m_error;
const bool updating = !ready && !m_error;
m_preview->setImage(m_result ? m_result->preview : QImage(), m_result ? QSize(m_result->settings.width, m_result->settings.height) : QSize(), updating);
m_spinner->setVisible(updating);
// A field keeps its typing while it holds the keys.
if (!m_width->hasFocus())
m_width->setText(QString::number(m_settings.width));
if (!m_height->hasFocus())
m_height->setText(QString::number(m_settings.height));
m_scale->setText(QStringLiteral("%1%").arg(std::lround(double(m_settings.width) / m_raster.image.width() * 100)));
const ExportFormat format = m_settings.format;
for (QWidget *each : m_jpegRow)
each->setVisible(format == ExportFormat::jpeg);
for (QWidget *each : m_pdfRow)
each->setVisible(format == ExportFormat::pdf);
for (QWidget *each : m_backgroundRow)
each->setVisible(format != ExportFormat::png);
m_percent->setText(QStringLiteral("%1%").arg(std::lround(m_settings.options.quality * 100)));
// The page: the export's pixels at the document's resolution.
const double dpi = m_raster.resolution > 0 ? m_raster.resolution : 72;
m_page->setText(QStringLiteral("%1 × %2 in at %3 DPI").arg(inches(m_settings.width / dpi), inches(m_settings.height / dpi)).arg(std::lround(dpi)));
m_note->setText(format == ExportFormat::png ? QStringLiteral("Transparency kept · sRGB") : QStringLiteral("sRGB"));
m_failure->setVisible(m_error.has_value());
m_failure->setText(m_error.value_or(QString()));
m_bytes->setVisible(ready);
m_bytes->setText(ready ? ByteCounts::file(m_result->data.size()) : QString());
m_updating->setVisible(updating);
m_export->setEnabled(ready);
// The zoom buttons name their keys and the zoom.
const double zoom = shownZoom();
const QString percent = QStringLiteral("%1%").arg(std::lround(zoom * 100));
const auto key = [this](size_t index) { return m_zoomKeys[index] ? m_zoomKeys[index]->key().toString(QKeySequence::NativeText) : QString(); };
m_fit->setEnabled(m_preview->zoom().has_value());
m_fit->setToolTip(QStringLiteral("Show the whole image (%1)").arg(key(0)));
m_zoomIn->setEnabled(JPEGPreview::step(zoom, 1).has_value());
m_zoomIn->setToolTip(QStringLiteral("Zoom in (%1), now %2. At 100% each pixel of the export is one pixel of the screen").arg(key(2), percent));
m_zoomOut->setEnabled(JPEGPreview::step(zoom, -1).has_value());
m_zoomOut->setToolTip(QStringLiteral("Zoom out (%1), now %2").arg(key(3), percent));
const QColor ink = palette().color(QPalette::ButtonText);
m_zoomIn->setIcon(LayerIcons::pixmap(LayerIcon::plusMagnifyingGlass, 16, ink, devicePixelRatioF()));
m_zoomOut->setIcon(LayerIcons::pixmap(LayerIcon::minusMagnifyingGlass, 16, ink, devicePixelRatioF()));
}
@@ -1,6 +1,7 @@
#pragma once
#include "Document/ColorPalette.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectController.h"
#include <QAbstractScrollArea>
#include <QWidget>
#include <array>
@@ -8,9 +9,13 @@
#include <functional>
#include <memory>
#include <optional>
#include <vector>
class QButtonGroup;
class QLabel;
class QProgressBar;
class QToolButton;
class PickerField;
class QShortcut;
class QPushButton;
class QSlider;
@@ -18,7 +23,7 @@ class QTimer;
class DialogColorSwatch;
class EditorSession;
// Swift's JPEGPreview: the encoded JPEG, fitted or zoomed, dragged.
// Swift's JPEGPreview: the export, fitted or zoomed, dragged.
class JPEGPreview : public QAbstractScrollArea {
Q_OBJECT
public:
@@ -29,9 +34,9 @@ public:
// The next step past `zoom`: 1 in, −1 out.
static std::optional<double> step(double zoom, int direction);
JPEGPreview(std::function<void()> zoomChanged, QWidget *parent);
// `pixels`: the export's size, which the image may undercut.
JPEGPreview(QSize pixels, std::function<void()> zoomChanged, QWidget *parent);
void setImage(const QImage &image, bool updating);
void setImage(const QImage &image, QSize pixels, bool updating);
// None fits; 1 is a screen pixel a pixel.
std::optional<double> zoom() const { return m_zoom; }
void setZoom(std::optional<double> zoom);
@@ -47,11 +52,12 @@ protected:
void mouseDoubleClickEvent(QMouseEvent *event) override;
private:
static QPixmap checkerboard(double ratio);
QSizeF shownSize(double zoom) const;
void updateRanges();
void showCursor(bool grabbing);
const QSize m_pixels;
QSize m_pixels;
// Each change of zoom, for the sheet's buttons.
const std::function<void()> m_zoomChanged;
QImage m_image;
@@ -63,46 +69,80 @@ private:
std::optional<std::pair<QPoint, QPoint>> m_drag;
};
// Swift's JPEGExportSheet: quality, matte and the encoded preview.
class JPEGExportSheet : public QWidget {
// Swift's ExportAsSheet: PNG, JPEG or PDF, sized and previewed.
class ExportAsSheet : public QWidget {
Q_OBJECT
public:
// The last export's quality, where the next one starts.
// The last export's JPEG quality, where the next one starts.
static const QString qualityKey;
// What Export… hands over: the bytes and their format.
struct Chosen {
QByteArray data;
ExportFormat format;
};
// What is written; each change makes a new preview.
struct Settings {
ExportFormat format;
JPEGOptions options;
int width;
int height;
bool operator==(const Settings &) const = default;
};
JPEGExportSheet(ExportRaster raster, EditorSession &session, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent = nullptr);
~JPEGExportSheet() override;
ExportAsSheet(ExportRaster raster, EditorSession &session, ExportFormat format, std::function<void(std::optional<Chosen>)> finish,
QWidget *parent = nullptr);
~ExportAsSheet() override;
const Settings &settings() const { return m_settings; }
protected:
// The zoom glyphs follow the theme.
void changeEvent(QEvent *event) override;
private:
struct Encoded {
Settings settings;
QByteArray data;
QImage preview;
};
void request();
void encode();
void setMatte(const PaletteColor &matte);
// Both sides follow one, the proportions kept, within the limits.
void setScale(double scale);
// Both fields show the size, typing or not.
void showSize();
double shownZoom() const;
void zoomBy(int direction);
void synchronize();
// The View menu's zoom keys, as remapped, zoom the preview.
void bindZoomKeys();
const ExportRaster m_raster;
EditorSession &m_session;
const std::function<void(std::optional<QByteArray>)> m_finish;
JPEGOptions m_options;
std::optional<JPEGResult> m_result;
std::optional<JPEGOptions> m_readyOptions;
const std::function<void(std::optional<Chosen>)> m_finish;
Settings m_settings;
std::optional<Encoded> m_result;
std::optional<QString> m_error;
// The newest request's flag, Swift's task cancellation.
std::shared_ptr<std::atomic_bool> m_cancelled;
QTimer *const m_wait;
JPEGPreview *const m_preview;
QProgressBar *const m_spinner;
QButtonGroup *const m_formats;
PickerField *const m_width;
PickerField *const m_height;
QToolButton *const m_scale;
QSlider *const m_quality;
QLabel *const m_percent;
QLabel *const m_page;
DialogColorSwatch *const m_matteSwatch;
// Rows shown by format: Quality for JPEG, Page for PDF.
std::vector<QWidget *> m_jpegRow, m_pdfRow, m_backgroundRow;
QLabel *const m_note;
QLabel *const m_failure;
QLabel *const m_bytes;
QLabel *const m_note;
QLabel *const m_updating;
QPushButton *const m_export;
QPushButton *const m_fit;
QPushButton *const m_zoomIn;
-282
View File
@@ -1,282 +0,0 @@
#include "UI/JPEGExportSheet.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/ByteCounts.h"
#include "UI/ColorPickerSheet+Dialog.h"
#include "UI/LayerIcons.h"
#include <QFutureWatcher>
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QProgressBar>
#include <QPushButton>
#include <QSettings>
#include <QShortcut>
#include <QSlider>
#include <QTimer>
#include <QVBoxLayout>
#include <QtConcurrent>
#include <algorithm>
#include <cmath>
const QString JPEGExportSheet::qualityKey = QStringLiteral("jpegExportQuality");
namespace {
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent)
{
auto *label = new QLabel(words, parent);
label->setTextFormat(Qt::PlainText);
QFont font = label->font();
font.setPixelSize(pixels);
font.setWeight(weight);
label->setFont(font);
label->setForegroundRole(role);
return label;
}
QString grouped(qint64 value)
{
return QLocale(QLocale::English, QLocale::UnitedStates).toString(value);
}
// Swift reads a finite number alone, clamped to 0–1.
double startQuality()
{
bool number = false;
const double saved = QSettings().value(JPEGExportSheet::qualityKey).toDouble(&number);
return number && std::isfinite(saved) ? std::clamp(saved, 0.0, 1.0) : JPEGOptions().quality;
}
struct Encoded {
std::optional<JPEGResult> result;
std::optional<QString> failure;
};
}
JPEGExportSheet::JPEGExportSheet(ExportRaster raster, EditorSession &session, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent)
: QWidget(parent), m_raster(std::move(raster)), m_session(session), m_finish(std::move(finish)), m_wait(new QTimer(this)), m_preview(new JPEGPreview(m_raster.image.size(), [this] { synchronize(); }, this)),
m_spinner(new QProgressBar(m_preview)), m_quality(new QSlider(Qt::Horizontal, this)),
m_percent(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)), m_matteSwatch(new DialogColorSwatch(
QStringLiteral("JPEG Background"), [this] { return PaletteColor{m_options.red, m_options.green, m_options.blue}; },
[this](const PaletteColor &matte) { setMatte(matte); }, session, this)),
m_failure(text(QString(), 13, QFont::Normal, QPalette::BrightText, this)), m_bytes(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)),
m_note(text(QString(), 13, QFont::Normal, QPalette::PlaceholderText, this)), m_export(new QPushButton(QStringLiteral("Export…"), this)),
m_fit(new QPushButton(QStringLiteral("Fit"), this)), m_zoomIn(new QPushButton(this)), m_zoomOut(new QPushButton(this))
{
m_options.quality = startQuality();
auto *column = new QVBoxLayout(this);
column->setContentsMargins(24, 24, 24, 24);
column->setSpacing(16);
// The title row sits 8 above; it inherits 8.
auto *top = new QVBoxLayout;
top->setSpacing(8);
auto *title = new QHBoxLayout;
title->addWidget(text(QStringLiteral("Export JPEG"), 17, QFont::Bold, QPalette::WindowText, this));
title->addStretch(1);
m_fit->setObjectName(QStringLiteral("jpegFit"));
m_zoomIn->setObjectName(QStringLiteral("jpegZoomIn"));
m_zoomIn->setAccessibleName(QStringLiteral("Zoom in"));
m_zoomOut->setObjectName(QStringLiteral("jpegZoomOut"));
m_zoomOut->setAccessibleName(QStringLiteral("Zoom out"));
for (QPushButton *button : {m_fit, m_zoomIn, m_zoomOut}) {
button->setAutoDefault(false);
title->addWidget(button);
}
connect(m_fit, &QPushButton::clicked, this, [this] { m_preview->setZoom(std::nullopt); });
connect(m_zoomIn, &QPushButton::clicked, this, [this] { m_preview->zoomBy(1); });
connect(m_zoomOut, &QPushButton::clicked, this, [this] { m_preview->zoomBy(-1); });
top->addLayout(title);
m_preview->setObjectName(QStringLiteral("jpegPreview"));
m_spinner->setObjectName(QStringLiteral("jpegSpinner"));
m_spinner->setRange(0, 0);
m_spinner->setTextVisible(false);
m_spinner->setGeometry(264, 149, 32, 32);
top->addWidget(m_preview);
column->addLayout(top);
auto *quality = new QHBoxLayout;
auto *qualityLabel = new QLabel(QStringLiteral("Quality"), this);
qualityLabel->setBuddy(m_quality);
// Swift's slider steps by hundredths.
m_quality->setObjectName(QStringLiteral("jpegQuality"));
m_quality->setRange(0, 100);
m_quality->setValue(int(std::lround(m_options.quality * 100)));
connect(m_quality, &QSlider::valueChanged, this, [this](int hundredths) {
m_options.quality = hundredths / 100.0;
request();
});
m_percent->setObjectName(QStringLiteral("jpegPercent"));
m_percent->setFixedWidth(45);
m_percent->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
quality->addWidget(qualityLabel);
quality->addWidget(m_quality, 1);
quality->addWidget(m_percent);
column->addLayout(quality);
// Its label and the app's picker on a swatch.
auto *matte = new QHBoxLayout;
matte->setSpacing(8);
matte->addWidget(new QLabel(QStringLiteral("Background for transparency"), this));
m_matteSwatch->setObjectName(QStringLiteral("jpegMatte"));
m_matteSwatch->setToolTip(QStringLiteral("Color that fills transparent areas"));
matte->addWidget(m_matteSwatch);
matte->addStretch(1);
column->addLayout(matte);
// The size, the result and the buttons share one row.
auto *buttons = new QHBoxLayout;
buttons->setSpacing(12);
QLabel *size = text(QStringLiteral("%1 × %2 px · sRGB").arg(grouped(m_raster.image.width()), grouped(m_raster.image.height())), 13, QFont::Normal,
QPalette::PlaceholderText, this);
size->setObjectName(QStringLiteral("jpegSize"));
buttons->addWidget(size);
buttons->addStretch(1);
m_failure->setObjectName(QStringLiteral("jpegError"));
m_bytes->setObjectName(QStringLiteral("jpegBytes"));
m_note->setObjectName(QStringLiteral("jpegNote"));
m_note->setText(QStringLiteral("Updating…"));
buttons->addWidget(m_failure);
buttons->addWidget(m_bytes);
buttons->addWidget(m_note);
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
cancel->setObjectName(QStringLiteral("jpegCancel"));
cancel->setAutoDefault(false);
connect(cancel, &QPushButton::clicked, this, [this] {
DialogColorSwatch::closePicker(m_session);
m_finish(std::nullopt);
});
m_export->setObjectName(QStringLiteral("jpegExport"));
m_export->setDefault(true);
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, m_export, cancel);
// Export rests until a result is ready.
connect(m_export, &QPushButton::clicked, this, [this] {
DialogColorSwatch::closePicker(m_session);
QSettings().setValue(qualityKey, m_options.quality);
m_finish(m_result.value().data);
});
buttons->addWidget(cancel);
buttons->addWidget(m_export);
column->addLayout(buttons);
m_wait->setObjectName(QStringLiteral("jpegWait"));
m_wait->setSingleShot(true);
m_wait->setInterval(200);
m_wait->setTimerType(Qt::PreciseTimer);
connect(m_wait, &QTimer::timeout, this, &JPEGExportSheet::encode);
bindZoomKeys();
connect(&ShortcutSettings::shared(), &ShortcutSettings::changed, this, &JPEGExportSheet::bindZoomKeys);
request();
}
// A window-modal sheet blocks the menus: it takes their keys.
void JPEGExportSheet::bindZoomKeys()
{
const std::array<QKeySequence, 4> originals{QKeySequence(Qt::CTRL | Qt::Key_0), QKeySequence(Qt::CTRL | Qt::Key_1),
QKeySequence(Qt::CTRL | Qt::Key_Equal), QKeySequence(Qt::CTRL | Qt::Key_Minus)};
const std::array<std::function<void()>, 4> actions{[this] { m_preview->setZoom(std::nullopt); }, [this] { m_preview->setZoom(1.0); },
[this] { m_preview->zoomBy(1); }, [this] { m_preview->zoomBy(-1); }};
for (size_t index = 0; index < m_zoomKeys.size(); ++index) {
if (!m_zoomKeys[index]) {
m_zoomKeys[index] = new QShortcut(this);
connect(m_zoomKeys[index], &QShortcut::activated, this, actions[index]);
}
m_zoomKeys[index]->setKey(ShortcutSettings::shared().menu(originals[index]));
}
synchronize();
}
// SwiftUI cancels the task when the sheet goes.
JPEGExportSheet::~JPEGExportSheet()
{
m_cancelled->store(true);
DialogColorSwatch::closePicker(m_session);
}
// Swift's task(id:): the last request stops; this waits 200 ms.
void JPEGExportSheet::request()
{
if (m_cancelled)
m_cancelled->store(true);
m_cancelled = std::make_shared<std::atomic_bool>(false);
m_error.reset();
m_wait->start();
synchronize();
}
void JPEGExportSheet::encode()
{
const std::shared_ptr<std::atomic_bool> cancelled = m_cancelled;
const JPEGOptions requested = m_options;
auto *watcher = new QFutureWatcher<Encoded>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, cancelled, requested] {
watcher->deleteLater();
// Swift's check after the await: a newer request supersedes.
if (cancelled->load())
return;
Encoded encoded = watcher->future().takeResult();
if (encoded.result) {
m_result = std::move(encoded.result);
m_readyOptions = requested;
} else {
m_error = encoded.failure.value();
}
synchronize();
});
watcher->setFuture(QtConcurrent::run([raster = m_raster, requested, cancelled]() -> Encoded {
try {
return {ImageExporter::jpeg(raster, requested, [cancelled] { return cancelled->load(); }), std::nullopt};
} catch (const CancellationError &) {
return {};
} catch (const ExportError &error) {
return {std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
void JPEGExportSheet::setMatte(const PaletteColor &matte)
{
JPEGOptions next = m_options;
next.red = matte.red;
next.green = matte.green;
next.blue = matte.blue;
// Equal options ask nothing, as Swift's task id.
if (next == m_options)
return;
m_options = next;
request();
}
void JPEGExportSheet::changeEvent(QEvent *event)
{
QWidget::changeEvent(event);
if (event->type() == QEvent::PaletteChange)
synchronize();
}
void JPEGExportSheet::synchronize()
{
const bool ready = m_result && m_readyOptions == m_options;
const bool updating = m_readyOptions != m_options && !m_error;
m_preview->setImage(m_result ? m_result->preview : QImage(), updating);
m_spinner->setVisible(updating);
m_percent->setText(QStringLiteral("%1%").arg(std::lround(m_options.quality * 100)));
m_failure->setVisible(m_error.has_value());
m_failure->setText(m_error.value_or(QString()));
m_bytes->setVisible(ready && !m_error);
m_bytes->setText(m_result ? ByteCounts::file(m_result->data.size()) : QString());
m_note->setVisible(!ready && !m_error);
m_export->setEnabled(ready && !m_error);
// The zoom buttons name their keys and the zoom.
const double zoom = m_preview->shownZoom();
const QString percent = QStringLiteral("%1%").arg(std::lround(zoom * 100));
const auto key = [this](size_t index) { return m_zoomKeys[index] ? m_zoomKeys[index]->key().toString(QKeySequence::NativeText) : QString(); };
m_fit->setEnabled(m_preview->zoom().has_value());
m_fit->setToolTip(QStringLiteral("Show the whole image (%1)").arg(key(0)));
m_zoomIn->setEnabled(JPEGPreview::step(zoom, 1).has_value());
m_zoomIn->setToolTip(QStringLiteral("Zoom in (%1), now %2. At 100% each pixel of the JPEG is one pixel of the screen, as on the canvas").arg(key(2), percent));
m_zoomOut->setEnabled(JPEGPreview::step(zoom, -1).has_value());
m_zoomOut->setToolTip(QStringLiteral("Zoom out (%1), now %2").arg(key(3), percent));
const QColor ink = palette().color(QPalette::ButtonText);
m_zoomIn->setIcon(LayerIcons::pixmap(LayerIcon::plusMagnifyingGlass, 16, ink, devicePixelRatioF()));
m_zoomOut->setIcon(LayerIcons::pixmap(LayerIcon::minusMagnifyingGlass, 16, ink, devicePixelRatioF()));
}
+1 -1
View File
@@ -124,7 +124,7 @@ const std::vector<ShortcutDefinition> &ShortcutDefinition::all()
std::vector<ShortcutDefinition> result{
entry("Undo", "z", 1, true), entry("Redo", "z", 9, true), entry("New Canvas", "n", 1, true),
entry("Open Project", "o", 1, true), entry("Save", "s", 1, true), entry("Save As", "s", 9, true),
entry("Export PNG", "e", 9, true), entry("Export JPEG", "s", 11, true), entry("Close Project", "w", 1, true),
entry("Export PNG", "e", 9, true), entry("Export JPEG", "s", 11, true), entry("Export As", "w", 11, true), entry("Close Project", "w", 1, true),
entry("Fit Canvas", "0", 1, true), entry("Search Commands", "f", 1, true), entry("Actual Pixels", "1", 1, true), entry("Zoom In", "=", 1, true),
entry("Zoom Out", "-", 1, true), entry("Show Transform Controls", "h", 1, true), entry("Cut", "x", 1, true),
entry("Copy", "c", 1, true), entry("Copy Merged", "c", 9, true), entry("Paste", "v", 1, true),