Files
hfut-bishe/client/gui/dialogs/ImageCropDialog.cpp
T
2026-05-14 13:30:06 +08:00

567 lines
18 KiB
C++

#include "dialogs/ImageCropDialog.h"
#include "core/image/ImageDecodeConfig.h"
#include "core/image/ImageFileLoader.h"
#include <QBoxLayout>
#include <QCursor>
#include <QDialogButtonBox>
#include <QLabel>
#include <QMouseEvent>
#include <QPainter>
#include <QPushButton>
#include <QResizeEvent>
#include <QWheelEvent>
#include <QtMath>
#include <algorithm>
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
# define CROP_LOCAL_POS_M(E) (E)->position()
# define CROP_LOCAL_POS_W(E) (E)->position()
#else
# define CROP_LOCAL_POS_M(E) (E)->localPos()
# define CROP_LOCAL_POS_W(E) (E)->posF()
#endif
namespace {
constexpr int kMinCropSidePx = 2;
/// 相对「适应窗口」倍率的乘子;范围极大以便实质任意缩放(仍避免数值溢出)
constexpr qreal kZoomMulMin = 1e-12;
constexpr qreal kZoomMulMax = 1e12;
constexpr qreal kHandleViewPx = 7.0;
enum class HitPart : quint8 {
None,
Inside,
N,
Ne,
E,
Se,
S,
Sw,
W,
Nw,
/// 平移视图(选区外左键拖动)
PanView
};
QRect clampRectToBounds(QRect r, const QRect& bounds) {
r = r.normalized();
if (r.width() < kMinCropSidePx || r.height() < kMinCropSidePx) {
return {};
}
if (r.left() < bounds.left()) {
r.moveLeft(bounds.left());
}
if (r.top() < bounds.top()) {
r.moveTop(bounds.top());
}
if (r.right() > bounds.right()) {
r.moveRight(bounds.right());
}
if (r.bottom() > bounds.bottom()) {
r.moveBottom(bounds.bottom());
}
if (r.width() < kMinCropSidePx || r.height() < kMinCropSidePx) {
return {};
}
return r;
}
} // namespace
class ImageCropDialog::CropView final : public QWidget {
public:
explicit CropView(QWidget* parent = nullptr)
: QWidget(parent) {
setMouseTracking(true);
setFocusPolicy(Qt::WheelFocus);
setMinimumSize(480, 320);
}
void setImage(const QImage& img) {
m_image = img;
m_zoom = 1.0;
m_pan = QPointF();
m_selImage = m_image.isNull() ? QRect() : QRect(QPoint(0, 0), m_image.size());
m_dragPart = HitPart::None;
updateGeometry();
update();
}
bool hasSelection() const {
return !m_image.isNull() && m_selImage.width() >= kMinCropSidePx && m_selImage.height() >= kMinCropSidePx;
}
QRect selectionInImagePixels() const {
if (m_image.isNull() || !hasSelection()) {
return {};
}
return m_selImage.normalized().intersected(QRect(0, 0, m_image.width(), m_image.height()));
}
void resetSelection() {
if (!m_image.isNull()) {
m_selImage = QRect(QPoint(0, 0), m_image.size());
} else {
m_selImage = {};
}
update();
}
protected:
void resizeEvent(QResizeEvent* e) override {
QWidget::resizeEvent(e);
update();
}
void paintEvent(QPaintEvent*) override {
QPainter p(this);
p.fillRect(rect(), palette().window());
if (m_image.isNull()) {
p.setPen(palette().text().color());
p.drawText(rect(), Qt::AlignCenter, QStringLiteral("无法加载图片"));
return;
}
qreal eff = 0;
QPointF tl;
computeLayout(eff, tl);
const QTransform imgToView = imageToViewTransform(eff, tl);
p.setRenderHint(QPainter::SmoothPixmapTransform, true);
p.setTransform(imgToView);
p.drawImage(QPoint(0, 0), m_image);
p.resetTransform();
const QRectF selView = imgToView.mapRect(QRectF(m_selImage));
const QRect sel = selView.toAlignedRect().intersected(rect());
const QColor shade(0, 0, 0, 120);
p.fillRect(QRect(0, 0, width(), sel.top()), shade);
p.fillRect(QRect(0, sel.bottom(), width(), height() - sel.bottom()), shade);
p.fillRect(QRect(0, sel.top(), sel.left(), sel.height()), shade);
p.fillRect(QRect(sel.right(), sel.top(), width() - sel.right(), sel.height()), shade);
p.setPen(QPen(QColor(255, 255, 255, 230), 2));
p.setBrush(Qt::NoBrush);
p.drawRect(sel);
const qreal hsz = kHandleViewPx;
const QPointF c[4] = {
selView.topLeft(),
selView.topRight(),
selView.bottomRight(),
selView.bottomLeft(),
};
p.setPen(QPen(QColor(255, 255, 255, 240), 1));
p.setBrush(QColor(255, 255, 255, 220));
for (const QPointF& pt : c) {
p.drawRect(QRectF(pt.x() - hsz * 0.5, pt.y() - hsz * 0.5, hsz, hsz));
}
}
void wheelEvent(QWheelEvent* e) override {
if (m_image.isNull()) {
e->ignore();
return;
}
qreal effOld = 0;
QPointF tlOld;
computeLayout(effOld, tlOld);
const QPointF cursor = CROP_LOCAL_POS_W(e);
const QPointF imgPt((cursor.x() - tlOld.x()) / effOld, (cursor.y() - tlOld.y()) / effOld);
const qreal steps = e->angleDelta().y() / 120.0;
const qreal factor = std::pow(1.1, steps);
const qreal newZoom = std::clamp(m_zoom * factor, kZoomMulMin, kZoomMulMax);
if (qFuzzyCompare(newZoom, m_zoom)) {
e->accept();
return;
}
m_zoom = newZoom;
qreal effNew = 0;
QPointF tlIgnored;
computeLayout(effNew, tlIgnored);
const QPointF tlNew(cursor.x() - imgPt.x() * effNew, cursor.y() - imgPt.y() * effNew);
const qreal iw = m_image.width();
const qreal ih = m_image.height();
const qreal vw = width();
const qreal vh = height();
const QPointF centerOff((vw - iw * effNew) / 2.0, (vh - ih * effNew) / 2.0);
m_pan = tlNew - centerOff;
e->accept();
update();
}
void mousePressEvent(QMouseEvent* e) override {
if (m_image.isNull() || e->button() != Qt::LeftButton) {
return;
}
qreal eff = 0;
QPointF tl;
computeLayout(eff, tl);
const QPointF viewPos = CROP_LOCAL_POS_M(e);
const QPointF img = viewToImage(viewPos, eff, tl);
m_dragPart = hitTest(img, eff);
if (m_dragPart == HitPart::None) {
m_dragPart = HitPart::PanView;
}
if (m_dragPart == HitPart::PanView) {
m_pressView = viewPos;
m_panAtPress = m_pan;
setCursor(Qt::ClosedHandCursor);
return;
}
m_pressImg = img;
m_startSel = m_selImage;
updateCursorForHover(viewPos, eff, tl);
}
void mouseMoveEvent(QMouseEvent* e) override {
if (m_image.isNull()) {
return;
}
qreal eff = 0;
QPointF tl;
computeLayout(eff, tl);
const QPointF viewPos = CROP_LOCAL_POS_M(e);
if ((e->buttons() & Qt::LeftButton) && m_dragPart == HitPart::PanView) {
m_pan = m_panAtPress + (viewPos - m_pressView);
update();
return;
}
if ((e->buttons() & Qt::LeftButton) && m_dragPart != HitPart::None && m_dragPart != HitPart::PanView) {
const QPointF img = viewToImage(viewPos, eff, tl);
applyDrag(m_dragPart, m_pressImg, img, m_startSel);
update();
return;
}
updateCursorForHover(viewPos, eff, tl);
}
void mouseReleaseEvent(QMouseEvent* e) override {
if (e->button() == Qt::LeftButton) {
m_dragPart = HitPart::None;
unsetCursor();
}
QWidget::mouseReleaseEvent(e);
}
private:
void computeLayout(qreal& outEffScale, QPointF& outTopLeft) const {
const qreal vw = width();
const qreal vh = height();
const qreal iw = m_image.width();
const qreal ih = m_image.height();
if (iw <= 0 || ih <= 0 || vw <= 1 || vh <= 1) {
outEffScale = 1.0;
outTopLeft = QPointF();
return;
}
const qreal fit = std::min(vw / iw, vh / ih);
const qreal eff = fit * m_zoom;
outEffScale = eff;
outTopLeft = m_pan + QPointF((vw - iw * eff) / 2.0, (vh - ih * eff) / 2.0);
}
QTransform imageToViewTransform(qreal eff, const QPointF& tl) const {
QTransform t;
t.translate(tl.x(), tl.y());
t.scale(eff, eff);
return t;
}
QPointF viewToImage(const QPointF& viewPt, qreal eff, const QPointF& tl) const {
return QPointF((viewPt.x() - tl.x()) / eff, (viewPt.y() - tl.y()) / eff);
}
qreal marginImage(qreal eff) const {
return std::max<qreal>(kMinCropSidePx, kHandleViewPx / std::max(eff, 1e-6));
}
HitPart hitTest(const QPointF& img, qreal eff) const {
const qreal iw = m_image.width();
const qreal ih = m_image.height();
const QRectF boundsF(0, 0, iw, ih);
if (!boundsF.contains(img)) {
return HitPart::PanView;
}
const qreal m = marginImage(eff);
const QRect r = m_selImage.normalized();
const bool onN = std::abs(img.y() - r.top()) <= m && img.x() >= r.left() - m && img.x() <= r.right() + m;
const bool onS = std::abs(img.y() - r.bottom()) <= m && img.x() >= r.left() - m && img.x() <= r.right() + m;
const bool onW = std::abs(img.x() - r.left()) <= m && img.y() >= r.top() - m && img.y() <= r.bottom() + m;
const bool onE = std::abs(img.x() - r.right()) <= m && img.y() >= r.top() - m && img.y() <= r.bottom() + m;
const bool cnw = std::abs(img.x() - r.left()) <= m && std::abs(img.y() - r.top()) <= m;
const bool cne = std::abs(img.x() - r.right()) <= m && std::abs(img.y() - r.top()) <= m;
const bool cse = std::abs(img.x() - r.right()) <= m && std::abs(img.y() - r.bottom()) <= m;
const bool csw = std::abs(img.x() - r.left()) <= m && std::abs(img.y() - r.bottom()) <= m;
if (cnw) {
return HitPart::Nw;
}
if (cne) {
return HitPart::Ne;
}
if (cse) {
return HitPart::Se;
}
if (csw) {
return HitPart::Sw;
}
if (onN && !onW && !onE) {
return HitPart::N;
}
if (onS && !onW && !onE) {
return HitPart::S;
}
if (onW && !onN && !onS) {
return HitPart::W;
}
if (onE && !onN && !onS) {
return HitPart::E;
}
const QRect fullImage(0, 0, m_image.width(), m_image.height());
if (r.contains(img.toPoint())) {
// 选区为整图时没有「可平移的选区」,框内左键用于平移视图
if (r.normalized() == fullImage.normalized()) {
return HitPart::PanView;
}
return HitPart::Inside;
}
return HitPart::PanView;
}
void updateCursorForHover(const QPointF& viewPt, qreal eff, const QPointF& tl) {
if (m_dragPart != HitPart::None) {
return;
}
const QPointF img = viewToImage(viewPt, eff, tl);
const HitPart h = hitTest(img, eff);
switch (h) {
case HitPart::PanView:
setCursor(Qt::OpenHandCursor);
break;
case HitPart::Inside:
setCursor(Qt::SizeAllCursor);
break;
case HitPart::N:
setCursor(Qt::SizeVerCursor);
break;
case HitPart::S:
setCursor(Qt::SizeVerCursor);
break;
case HitPart::W:
setCursor(Qt::SizeHorCursor);
break;
case HitPart::E:
setCursor(Qt::SizeHorCursor);
break;
case HitPart::Nw:
case HitPart::Se:
setCursor(Qt::SizeFDiagCursor);
break;
case HitPart::Ne:
case HitPart::Sw:
setCursor(Qt::SizeBDiagCursor);
break;
default:
unsetCursor();
break;
}
}
void applyDrag(HitPart part, const QPointF& pressImg, const QPointF& curImg, const QRect& start) {
const QRect bounds(0, 0, m_image.width(), m_image.height());
QRect r = start.normalized();
const int L = r.left();
const int T = r.top();
const int R = r.right();
const int B = r.bottom();
auto clampX = [&](int x) { return std::clamp(x, bounds.left(), bounds.right()); };
auto clampY = [&](int y) { return std::clamp(y, bounds.top(), bounds.bottom()); };
switch (part) {
case HitPart::Inside: {
const QPoint delta = (curImg - pressImg).toPoint();
r.translate(delta);
r = clampRectToBounds(r, bounds);
break;
}
case HitPart::N: {
int nt = clampY(int(std::round(curImg.y())));
nt = std::min(nt, B - kMinCropSidePx);
r = QRect(L, nt, R - L + 1, B - nt + 1);
break;
}
case HitPart::S: {
int nb = clampY(int(std::round(curImg.y())));
nb = std::max(nb, T + kMinCropSidePx);
r = QRect(L, T, R - L + 1, nb - T + 1);
break;
}
case HitPart::W: {
int nl = clampX(int(std::round(curImg.x())));
nl = std::min(nl, R - kMinCropSidePx);
r = QRect(nl, T, R - nl + 1, B - T + 1);
break;
}
case HitPart::E: {
int nr = clampX(int(std::round(curImg.x())));
nr = std::max(nr, L + kMinCropSidePx);
r = QRect(L, T, nr - L + 1, B - T + 1);
break;
}
case HitPart::Nw: {
int nl = clampX(int(std::round(curImg.x())));
int nt = clampY(int(std::round(curImg.y())));
nl = std::min(nl, R - kMinCropSidePx);
nt = std::min(nt, B - kMinCropSidePx);
r = QRect(QPoint(nl, nt), QPoint(R, B)).normalized();
break;
}
case HitPart::Ne: {
int nr = clampX(int(std::round(curImg.x())));
int nt = clampY(int(std::round(curImg.y())));
nr = std::max(nr, L + kMinCropSidePx);
nt = std::min(nt, B - kMinCropSidePx);
r = QRect(QPoint(L, nt), QPoint(nr, B)).normalized();
break;
}
case HitPart::Se: {
int nr = clampX(int(std::round(curImg.x())));
int nb = clampY(int(std::round(curImg.y())));
nr = std::max(nr, L + kMinCropSidePx);
nb = std::max(nb, T + kMinCropSidePx);
r = QRect(QPoint(L, T), QPoint(nr, nb)).normalized();
break;
}
case HitPart::Sw: {
int nl = clampX(int(std::round(curImg.x())));
int nb = clampY(int(std::round(curImg.y())));
nl = std::min(nl, R - kMinCropSidePx);
nb = std::max(nb, T + kMinCropSidePx);
r = QRect(QPoint(nl, T), QPoint(R, nb)).normalized();
break;
}
case HitPart::PanView:
break;
default:
break;
}
m_selImage = clampRectToBounds(r, bounds);
if (m_selImage.isEmpty()) {
m_selImage = start;
}
}
private:
QImage m_image;
QRect m_selImage;
double m_zoom = 1.0;
QPointF m_pan;
HitPart m_dragPart = HitPart::None;
QPointF m_pressImg;
QRect m_startSel;
QPointF m_pressView;
QPointF m_panAtPress;
};
ImageCropDialog::ImageCropDialog(const QString& imagePath, QWidget* parent)
: QDialog(parent),
m_imagePath(imagePath) {
setWindowTitle(QStringLiteral("裁剪图片"));
setModal(true);
resize(900, 600);
loadImageOrClose();
rebuildUi();
}
void ImageCropDialog::loadImageOrClose() {
if (!core::image_file::probeImagePixelSize(m_imagePath, &m_sourceLogicalSize)) {
m_sourceLogicalSize = QSize();
}
m_image =
core::image_file::loadImageLimited(m_imagePath, core::image_decode::kPreviewMaxPixels);
if (m_image.isNull()) {
reject();
return;
}
if (!m_sourceLogicalSize.isValid() || m_sourceLogicalSize.width() <= 0 ||
m_sourceLogicalSize.height() <= 0) {
m_sourceLogicalSize = m_image.size();
}
}
void ImageCropDialog::rebuildUi() {
auto* root = new QVBoxLayout(this);
auto* hint = new QLabel(
QStringLiteral(
"默认已框选整张图。滚轮可连续缩放;拖拽边或角调整裁剪,框内拖拽移动选区;选区外或留白处左键拖动平移视图。「重置」恢复整图选区。"
" 若已链接 libvips,超大图用流式解码与预览;否则回退 Qt。确定裁剪后坐标按原图逻辑像素对齐。"),
this);
hint->setWordWrap(true);
root->addWidget(hint);
m_view = new CropView(this);
m_view->setImage(m_image);
root->addWidget(m_view, 1);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
m_okButton = buttons->button(QDialogButtonBox::Ok);
auto* resetBtn = new QPushButton(QStringLiteral("重置"), this);
buttons->addButton(resetBtn, QDialogButtonBox::ActionRole);
connect(resetBtn, &QPushButton::clicked, this, &ImageCropDialog::onReset);
connect(buttons, &QDialogButtonBox::accepted, this, &ImageCropDialog::onOk);
connect(buttons, &QDialogButtonBox::rejected, this, &ImageCropDialog::reject);
root->addWidget(buttons);
}
bool ImageCropDialog::hasValidSelection() const {
return m_view && m_view->hasSelection();
}
QRect ImageCropDialog::selectedRectInImagePixels() const {
if (!m_view) {
return {};
}
const QRect inPreview = m_view->selectionInImagePixels();
if (!inPreview.isValid() || m_image.isNull()) {
return {};
}
if (m_sourceLogicalSize.isValid() && m_sourceLogicalSize != m_image.size()) {
return core::image_decode::mapRectBetweenSizes(inPreview, m_image.size(), m_sourceLogicalSize);
}
return inPreview;
}
void ImageCropDialog::onReset() {
if (m_view) {
m_view->resetSelection();
}
}
void ImageCropDialog::onOk() {
accept();
}