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

+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),