#include "dialogs/ImageCropDialog.h" #include "core/image/ImageDecodeConfig.h" #include "core/image/ImageFileLoader.h" #include #include #include #include #include #include #include #include #include #include #include #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(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(); }