PHP项目Symfony form与图片裁剪

wen PHP项目 2

本文目录导读:

PHP项目Symfony form与图片裁剪

  1. 核心方案:VichUploaderBundle + Cropper.js
  2. 替代方案:使用Dropzone.js + Cropper.js
  3. 性能优化建议
  4. 完整示例(GitHub链接)

在Symfony项目中结合form组件与图片裁剪功能,主要涉及几个方面的技术栈整合,我会从最实用的方案入手,逐步深入到高级定制。

核心方案:VichUploaderBundle + Cropper.js

这是目前在Symfony生态中最成熟、文档最完善的图片裁剪与上传方案。

安装必要的包

composer require vich/uploader-bundle
composer require liip/imagine-bundle  # 图片处理
npm install cropperjs  # 前端裁剪库

配置VichUploader

config/packages/vich_uploader.yaml

vich_uploader:
    db_driver: orm
    mappings:
        product_image:
            uri_prefix: /uploads/images
            upload_destination: '%kernel.project_dir%/public/uploads/images'
            namer: vich_uploader.namer_uniqid
            inject_on_load: false
            delete_on_update: true
            delete_on_remove: true

创建实体

src/Entity/Product.php

<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Annotation as Vich;
use Symfony\Component\Validator\Constraints as Assert;
#[Vich\Uploadable]
#[ORM\Entity]
class Product
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;
    #[ORM\Column(length: 255)]
    private ?string $image = null;
    #[Vich\UploadableField(mapping: 'product_image', fileNameProperty: 'image')]
    #[Assert\Image(
        maxSize: '5M',
        mimeTypes: ['image/jpeg', 'image/png', 'image/webp']
    )]
    private ?File $imageFile = null;
    #[ORM\Column(type: 'json', nullable: true)]
    private ?array $cropData = null;  // 存储裁剪坐标
    #[ORM\Column]
    private ?\DateTimeImmutable $updatedAt = null;
    // Getters and Setters...
    public function setImageFile(?File $imageFile = null): void
    {
        $this->imageFile = $imageFile;
        if ($imageFile) {
            $this->updatedAt = new \DateTimeImmutable();
        }
    }
}

构建带有裁剪功能的FormType

src/Form/ProductType.php

<?php
namespace App\Form;
use App\Entity\Product;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\File as FileConstraint;
class ProductType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('imageFile', FileType::class, [
                'label' => 'Product Image',
                'required' => false,
                'mapped' => false,
                'constraints' => [
                    new FileConstraint([
                        'maxSize' => '5M',
                        'mimeTypes' => [
                            'image/jpeg',
                            'image/png',
                            'image/webp',
                        ],
                        'mimeTypesMessage' => 'Please upload a valid image file',
                    ])
                ],
                'attr' => [
                    'accept' => 'image/*',
                    'class' => 'image-upload-input'
                ]
            ])
            ->add('cropData', HiddenType::class, [
                'mapped' => false,
                'attr' => ['class' => 'crop-data-input']
            ]);
    }
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Product::class,
        ]);
    }
}

前端模板实现

templates/product/_form.html.twig

{% block body %}
    {{ form_start(form, {'attr': {'class': 'product-form'}}) }}
    <div class="image-upload-container">
        <div class="form-group">
            {{ form_label(form.imageFile) }}
            {{ form_widget(form.imageFile) }}
            {{ form_errors(form.imageFile) }}
        </div>
        {# 裁剪预览区域 #}
        <div class="crop-container" style="display: none;">
            <div class="crop-preview">
                <img id="crop-image" src="" alt="Crop preview">
            </div>
            <div class="crop-actions">
                <button type="button" id="apply-crop" class="btn btn-primary">
                    Apply Crop
                </button>
                <button type="button" id="cancel-crop" class="btn btn-secondary">
                    Cancel
                </button>
            </div>
        </div>
        {# 裁剪数据隐藏字段 #}
        {{ form_widget(form.cropData) }}
    </div>
    <button type="submit" class="btn btn-success">Save</button>
    {{ form_end(form) }}
{% endblock %}

JavaScript实现裁剪功能

assets/js/crop-upload.js

import Cropper from 'cropperjs';
import 'cropperjs/dist/cropper.css';
class ImageCropUpload {
    constructor() {
        this.cropper = null;
        this.init();
    }
    init() {
        const fileInput = document.querySelector('.image-upload-input');
        const cropContainer = document.querySelector('.crop-container');
        const cropImage = document.getElementById('crop-image');
        const applyBtn = document.getElementById('apply-crop');
        const cancelBtn = document.getElementById('cancel-crop');
        const cropDataInput = document.querySelector('.crop-data-input');
        if (!fileInput) return;
        fileInput.addEventListener('change', (e) => {
            const file = e.target.files[0];
            if (file) {
                const reader = new FileReader();
                reader.onload = (event) => {
                    cropImage.src = event.target.result;
                    cropContainer.style.display = 'block';
                    // 初始化Cropper
                    if (this.cropper) {
                        this.cropper.destroy();
                    }
                    this.cropper = new Cropper(cropImage, {
                        aspectRatio: 16 / 9,
                        viewMode: 1,
                        dragMode: 'move',
                        autoCropArea: 0.8,
                        restore: false,
                        guides: true,
                        center: true,
                        highlight: false,
                        cropBoxMovable: true,
                        cropBoxResizable: true,
                        toggleDragModeOnDblclick: false,
                    });
                };
                reader.readAsDataURL(file);
            }
        });
        applyBtn.addEventListener('click', () => {
            if (this.cropper) {
                const canvas = this.cropper.getCroppedCanvas({
                    width: 800,
                    height: 450,
                    imageSmoothingEnabled: true,
                    imageSmoothingQuality: 'high',
                });
                // 获取裁剪坐标
                const cropData = this.cropper.getData();
                // 将裁剪后的图片转换为Blob
                canvas.toBlob((blob) => {
                    // 这里可以上传到服务器或做其他处理
                    // 存储裁剪数据
                    cropDataInput.value = JSON.stringify({
                        x: cropData.x,
                        y: cropData.y,
                        width: cropData.width,
                        height: cropData.height,
                        rotate: cropData.rotate,
                        scaleX: cropData.scaleX,
                        scaleY: cropData.scaleY
                    });
                    // 显示裁剪后的预览
                    this.showCroppedPreview(canvas);
                    // 隐藏裁剪界面
                    cropContainer.style.display = 'none';
                }, 'image/jpeg', 0.95);
            }
        });
        cancelBtn.addEventListener('click', () => {
            if (this.cropper) {
                this.cropper.destroy();
                this.cropper = null;
            }
            cropContainer.style.display = 'none';
            fileInput.value = '';
        });
    }
    showCroppedPreview(canvas) {
        const previewContainer = document.querySelector('.cropped-preview') || 
            this.createPreviewContainer();
        previewContainer.innerHTML = '';
        previewContainer.appendChild(canvas);
    }
    createPreviewContainer() {
        const container = document.createElement('div');
        container.className = 'cropped-preview';
        container.style.cssText = `
            margin-top: 1rem;
            padding: 1rem;
            border: 2px solid #dee2e6;
            border-radius: 4px;
        `;
        const label = document.createElement('h5');
        label.textContent = 'Cropped Image Preview';
        container.appendChild(label);
        document.querySelector('.image-upload-container').appendChild(container);
        return container;
    }
}
// 初始化
document.addEventListener('DOMContentLoaded', () => {
    new ImageCropUpload();
});

控制器处理

src/Controller/ProductController.php

<?php
namespace App\Controller;
use App\Entity\Product;
use App\Form\ProductType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class ProductController extends AbstractController
{
    #[Route('/product/new', name: 'product_new')]
    public function new(Request $request, EntityManagerInterface $em): Response
    {
        $product = new Product();
        $form = $this->createForm(ProductType::class, $product);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            // 获取裁剪数据
            $cropData = $form->get('cropData')->getData();
            if ($cropData) {
                $cropDataArray = json_decode($cropData, true);
                $product->setCropData($cropDataArray);
            }
            // 处理图片上传
            $imageFile = $form->get('imageFile')->getData();
            if ($imageFile) {
                // VichUploader会自动处理文件上传
                $product->setImageFile($imageFile);
            }
            $em->persist($product);
            $em->flush();
            $this->addFlash('success', 'Product created successfully!');
            return $this->redirectToRoute('product_list');
        }
        return $this->render('product/new.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}

使用LiipImagine处理裁剪后的图片

config/packages/liip_imagine.yaml

liip_imagine:
    resolvers:
        default:
            web_path: ~
    filter_sets:
        cache: ~
        product_thumb:
            quality: 85
            filters:
                thumbnail: { size: [300, 169], mode: outbound }
        product_cropped:
            quality: 90
            filters:
                crop:
                    start: [0, 0]
                    size: [800, 450]

高级功能:服务端裁剪

如果你需要在服务端进行更精确的裁剪,可以创建自定义服务:

src/Service/ImageCropper.php

<?php
namespace App\Service;
use Imagine\Gd\Imagine;
use Imagine\Image\Box;
use Imagine\Image\Point;
class ImageCropper
{
    private Imagine $imagine;
    public function __construct()
    {
        $this->imagine = new Imagine();
    }
    public function crop(string $imagePath, array $cropData): string
    {
        $image = $this->imagine->open($imagePath);
        // 裁剪坐标
        $x = (int) $cropData['x'];
        $y = (int) $cropData['y'];
        $width = (int) $cropData['width'];
        $height = (int) $cropData['height'];
        // 执行裁剪
        $image->crop(new Point($x, $y), new Box($width, $height));
        // 保存裁剪后的图片
        $croppedPath = str_replace('.', '_cropped.', $imagePath);
        $image->save($croppedPath, ['quality' => 90]);
        return $croppedPath;
    }
}

Webpack Encore配置

webpack.config.js

const Encore = require('@symfony/webpack-encore');
Encore
    .setOutputPath('public/build/')
    .setPublicPath('/build')
    .addEntry('app', './assets/js/app.js')
    .addStyleEntry('app', './assets/styles/app.css')
    .enableSingleRuntimeChunk()
    .splitEntryChunks()
    .enableStimulusBridge('./assets/controllers.json')
    .enableSassLoader()
    .enablePostCssLoader()
    .enableVueLoader()
    .copyFiles({
        from: './assets/images',
        to: 'images/[path][name].[hash:8].[ext]',
    })
;
module.exports = Encore.getWebpackConfig();

替代方案:使用Dropzone.js + Cropper.js

如果你需要更现代的上传体验,可以考虑集成Dropzone.js:

npm install dropzone cropperjs

性能优化建议

  1. 图片压缩:使用TinyPNG或服务端压缩
  2. CDN加速:使用Cloudinary或Imgix进行云存储
  3. 懒加载:使用Lazy Loading for images
  4. 缓存策略:实现HTTP缓存头
  5. 格式选择:使用WebP格式(在支持的情况下)

完整示例(GitHub链接)

你可以参考这个开源项目:Symfony Image Crop Bundle

这个方案涵盖了前端裁剪、后端处理、数据持久化等完整流程,可以根据具体需求进行调整,如果需要更具体的实现细节,比如多图片上传、视频裁剪等功能,可以继续深入探讨。

抱歉,评论功能暂时关闭!