Cropper.js & ImageSharp
A simple demo showing the usage of Cropper.js in combination with the backend processing of the SixLabors ImageSharp library.
View source on GitHubCode Snippets
I wanted to use the data generated with the Advanced Cropper.js Canvas and process the image server side.
This demo can Rotate, Crop, Flip horizontal and vertical, zoom out and crop an area larger than the original image. The JS can crop the image and send the data to the server also as seen here
, but I also want to do the cropping etc on already saved images or in a Windows enviroment.
When looking for a working demo and/or example I found out there were none. And the few examples I could find did not handle rotation or cropping with dimesions larger than the source image (correctly).
So I created it using SixLabors ImageSharp.
This example uses MVC, but it can easily be adapted for other .Net purposes.
For simplicity there is no error checking or input validation for the correct file type etc.
Note that you also need the ImageSharp Drawing library.
Cropper.js generates data that looks like this. I deserialize this in the backend to perform the cropping actions. The class CropperData
is used for that. Note that this example uses the "basic" cropper as seen here.
{ "x": 377.4249874715656, "y": 524.5012332206522, "width": 1285.0810512058467, "height": 722.8580913032888, "rotate": 45, "scaleX": 1, "scaleY": 1 }
So first a Class to Deserialize the Cropper data into.
public class CropperData { public double x { get; set; } public double y { get; set; } public double width { get; set; } public double height { get; set; } public int rotate { get; set; } public int scaleX { get; set; } public int scaleY { get; set; } }
The method that does all the work.
using System; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Drawing; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using System.IO; public byte[] doCropperStuff(CropperData cropperData, byte[] oldImage) { //some variables int cropX = (int)Math.Abs(cropperData.x); int cropY = (int)Math.Abs(cropperData.y); int cropWidth = (int)cropperData.width; int cropHeight = (int)cropperData.height; int imageWidth = 0; int imageHeight = 0; int posX = 0; int posY = 0; //the background color used when the crop dimensions are outside the image var fillColor = new Rgba32(96, 111, 145); //create a new memorystream and load the image using (var stream = new MemoryStream()) using (var image = Image.Load(new MemoryStream(oldImage))) { //auto orient the image image.Mutate(x => x.AutoOrient()); //flip horizontal if (cropperData.scaleX == -1) { image.Mutate(x => x.Flip(FlipMode.Horizontal)); } //flip vertical if (cropperData.scaleY == -1) { image.Mutate(x => x.Flip(FlipMode.Vertical)); } //rotate if (cropperData.rotate != 0) { image.Mutate(x => x.Rotate(cropperData.rotate).BackgroundColor(fillColor)); } imageWidth = image.Width; imageHeight = image.Height; //check the dimensions and position of the crop and calculate image size and crop position //X-axis if (cropperData.x < 0 && cropX + cropWidth > imageWidth) { imageWidth = cropWidth; posX = cropX; cropX = 0; } else if (cropperData.x < 0) { imageWidth = imageWidth + cropX; posX = cropX; cropX = 0; } else if (cropX + cropWidth > imageWidth) { imageWidth = cropX + cropWidth; } //Y-axis if (cropperData.y < 0 && cropY + cropHeight > imageHeight) { imageHeight = cropHeight; posY = cropY; cropY = 0; } else if (cropperData.y < 0) { imageHeight = imageHeight + cropY; posY = cropY; cropY = 0; } else if (cropY + cropHeight > imageHeight) { imageHeight = cropY + cropHeight; } //create a new image with the correct dimension for the image and the crop using (var newImage = new Image<Rgba32>(Configuration.Default, imageWidth, imageHeight, fillColor)) { //position the image onto the new one newImage.Mutate(x => x.DrawImage(image, new Point(posX, posY), 1)); //now do the actual cropping newImage.Mutate(x => x.Crop(new Rectangle(cropX, cropY, cropWidth, cropHeight)).BackgroundColor(fillColor)); //set the compression level and save the image to a memory stream (100 = best quality image) newImage.Save(stream, new JpegEncoder { Quality = 100 }); //maak van de stream een byte array return stream.ToArray(); } } }
The Controller.
using System; using System.Web.Mvc; using WebApplication1.Models; using System.IO; using Newtonsoft.Json; namespace WebApplication1.Controllers { public class ExampleController : Controller { public ActionResult Index() { return View(new ExampleViewModel()); } [HttpPost] public ActionResult Index(ExampleViewModel model) { //check if the values are present if (model.image_upload == null || string.IsNullOrEmpty(model.cropper_data)) { return View(model); } //use newtonsoft.json to deserialize the posted cropper data into a custom class var cropperData = JsonConvert.DeserializeObject<CropperData>(model.cropper_data); //use a memorystream to get the uploaded image as a byte array using (var stream = new MemoryStream()) { model.image_upload.InputStream.Position = 0; model.image_upload.InputStream.CopyTo(stream); //get the cropped image as byte array var binary_image = doCropperStuff(cropperData, stream.ToArray()); //make the image a base64 stream to display on the page model.cropper_image = string.Format("data:image/jpeg;base64,{0}", Convert.ToBase64String(binary_image)); } return View(model); } } }
The Model.
using System; using System.ComponentModel.DataAnnotations; using System.Web; namespace WebApplication1.Models { public class ExampleViewModel { [Display(Name = "Upload an image")] public HttpPostedFileBase image_upload { get; set; } public string cropper_data { get; set; } public string cropper_image { get; set; } } }
The front-end HTML in Razor.
@model WebApplication1.Models.HomeViewModel @using (Html.BeginForm("Index", "Home", FormMethod.Post, new { @enctype = "multipart/form-data", @role = "form" })) { <div class="container bg-white pt-3"> <div class="row"> <div class="col-6"> <!-- file upload control --> <div class="form-group"> @Html.LabelFor(m => m.image_upload) <div class="custom-file"> @Html.TextBoxFor(m => m.image_upload, new { type = "file", @class = "custom-file-input", capture = "camera", accept = ".bmp,.gif,.jpg,.jpeg,.png" }) <label class="custom-file-label"></label> </div> </div> </div> </div> @if (!string.IsNullOrEmpty(Model.cropper_image)) { <!-- processed image from server --> <div class="row"> <div class="col-6"> <img src="@Model.cropper_image" class="img-fluid img-thumbnail" /> </div> </div> } <div id="container" style="display:none"> <div class="row"> <div class="col-6 pt-3 pb-3"> <label for="@Html.IdFor(m => m.cropper_data)"> Data generated by Cropper.js. You can also paste the data from the <a target="_blank" href="https://fengyuanchen.github.io/cropperjs/">Project Site.</a> To get the data there, click on the "Get Data" button and paste the code in the textarea below. </label> @Html.TextAreaFor(m => m.cropper_data, new { @class = "form-control" }) </div> </div> <div class="row"> <div class="col-6"> <!-- the image to be cropped --> <img id="image" style="max-height: 400px;"> </div> </div> <div class="row"> <div class="col" id="cropper-actions"> <!-- the cropper buttons --> <div class="cropper-buttons" id="cropper-buttons-container"> <div class="btn-group pt-3"> <button type="button" class="btn btn-primary" data-method="zoom" data-option="0.1" data-bs-toggle="tooltip" title="Zoom in"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <circle cx="11" cy="11" r="8"></circle> <line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="11" y1="8" x2="11" y2="14"></line> <line x1="8" y1="11" x2="14" y2="11"></line> </svg> </button> <button type="button" class="btn btn-primary" data-method="zoom" data-option="-0.1" data-bs-toggle="tooltip" title="Zoom out"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <circle cx="11" cy="11" r="8"></circle> <line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="8" y1="11" x2="14" y2="11"></line> </svg> </button> <button type="button" class="btn btn-primary" data-method="zoomTo" data-option="1" data-bs-toggle="tooltip" title="Zoom 100%"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path> </svg> </button> </div> <div class="btn-group pt-3"> <button type="button" class="btn btn-primary" data-method="move" data-option="-10" data-second-option="0" data-bs-toggle="tooltip" title="Move left"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <line x1="19" y1="12" x2="5" y2="12"></line> <polyline points="12 19 5 12 12 5"></polyline> </svg> </button> <button type="button" class="btn btn-primary" data-method="move" data-option="10" data-second-option="0" data-bs-toggle="tooltip" title="Move right"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <line x1="5" y1="12" x2="19" y2="12"></line> <polyline points="12 5 19 12 12 19"></polyline> </svg> </button> <button type="button" class="btn btn-primary" data-method="move" data-option="0" data-second-option="-10" data-bs-toggle="tooltip" title="Move up"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <line x1="12" y1="19" x2="12" y2="5"></line> <polyline points="5 12 12 5 19 12"></polyline> </svg> </button> <button type="button" class="btn btn-primary" data-method="move" data-option="0" data-second-option="10" data-bs-toggle="tooltip" title="Move down"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <line x1="12" y1="5" x2="12" y2="19"></line> <polyline points="19 12 12 19 5 12"></polyline> </svg> </button> </div> <div class="btn-group pt-3"> <button type="button" class="btn btn-primary" data-method="rotate" data-option="-10" data-bs-toggle="tooltip" title="Rotate CCW"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <polyline points="1 4 1 10 7 10"></polyline> <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path> </svg> </button> <button type="button" class="btn btn-primary" data-method="rotate" data-option="10" data-bs-toggle="tooltip" title="Rotate CW"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <polyline points="23 4 23 10 17 10"></polyline> <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path> </svg> </button> </div> <div class="btn-group pt-3"> <button type="button" class="btn btn-primary rotate-cw" data-method="scaleX" data-option="-1" data-bs-toggle="tooltip" title="Flip horizontal"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);"> <polyline points="15 3 21 3 21 9"></polyline> <polyline points="9 21 3 21 3 15"></polyline> <line x1="21" y1="3" x2="14" y2="10"></line> <line x1="3" y1="21" x2="10" y2="14"></line> </svg> </button> <button type="button" class="btn btn-primary rotate-ccw" data-method="scaleY" data-option="-1" data-bs-toggle="tooltip" title="Flip vertical"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(-45deg);"> <polyline points="15 3 21 3 21 9"></polyline> <polyline points="9 21 3 21 3 15"></polyline> <line x1="21" y1="3" x2="14" y2="10"></line> <line x1="3" y1="21" x2="10" y2="14"></line> </svg> </button> </div> <div class="btn-group pt-3"> <button type="button" class="btn btn-warning" data-method="reset" data-bs-toggle="tooltip" title="Reset"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"></path> <line x1="18" y1="9" x2="12" y2="15"></line> <line x1="12" y1="9" x2="18" y2="15"></line> </svg> </button> </div> </div> </div> </div> <div class="row"> <div class="col pt-4"> <!-- upload and crop button --> <button class="btn btn-primary" type="submit"> Upload and Crop </button> </div> </div> </div> <div class="row"> <div class="col text-center pt-5 pb-3"> <a target="_blank" href="https://www.vanderwaal.eu"> <img src="/images/vdwwd.png" alt="van der Waal Webdesign" title="van der Waal Webdesign" /> </a> </div> </div> </div> } <script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.bundle.min.js" integrity="sha384-LtrjvnR4Twt/qOuYxE721u19sVFLVSA4hf/rRt6PrZTmiPltdZcI7q7PXQBYTKyf" crossorigin="anonymous"></script> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.7/cropper.min.css" integrity="sha512-oG+0IPCSL2awaygM/2l1hPUgHDNnOWji9utPHodoAGbXwLH9yvgD7uRjFxdiKnDr+rx8ejxXYSsUBkcKFR7i0w==" crossorigin="anonymous" /> <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.7/cropper.min.js" integrity="sha512-N4T9zTrqZUWCEhVU2uD0m47ADCWYRfEGNQ+dx/lYdQvOn+5FJZxcyHOY68QKsjTEC7Oa234qhXFhjPGQu6vhqg==" crossorigin="anonymous"></script> <script src="/cropper.js"></script>
And finally the Javascript.
var cropper; $(document).ready(function () { var $cropper_image = $('#image'); var $container = $('#container'); //check if a image is selected in the file upload $('.custom-file-input').change(function () { var file = $(this)[0].files[0]; //destroy cropper if it exists if (cropper != null) { cropper.destroy(); } //read the image and show on the page var reader = new FileReader(); reader.onload = function (e) { $cropper_image.attr('src', e.target.result); $cropper_image.show(); $container.show(); //start cropper.js startCropper(); }; reader.readAsDataURL(file); }); //tooltips bindTooltip(); }); //this initializes the cropper function startCropper() { var image = document.getElementById('image'); var actions = document.getElementById('cropper-actions'); //this makes the tooltip dissapear on the button clicks otherwise they could be persistent $('.cropper-buttons .btn:not(.bound)').addClass('bound').bind('click', function () { $(this).tooltip('hide'); }); //cropper options var options = { aspectRatio: NaN, crop: function () { var cropperdata = JSON.stringify(cropper.getData(true)); $('#cropper_data').val(cropperdata); } }; //build the cropper cropper = new Cropper(image, options); //bind the cropper functions to the buttons actions.querySelector('.cropper-buttons').onclick = function (event) { var e = event || window.event; var target = e.target || e.srcElement; var cropped; var result; var input; var data; if (!cropper) { return; } while (target !== this) { if (target.getAttribute('data-method')) { break; } target = target.parentNode; } if (target === this || target.disabled || target.className.indexOf('disabled') > -1) { return; } data = { method: target.getAttribute('data-method'), target: target.getAttribute('data-target'), option: target.getAttribute('data-option') || undefined, secondOption: target.getAttribute('data-second-option') || undefined }; cropped = cropper.cropped; if (data.method) { if (typeof data.target !== 'undefined') { input = document.querySelector(data.target); if (!target.hasAttribute('data-option') && data.target && input) { try { data.option = JSON.parse(input.value); } catch (e) { console.log(e.message); } } } result = cropper[data.method](data.option, data.secondOption); switch (data.method) { case 'rotate': if (cropped && options.viewMode > 0) { cropper.crop(); } break; case 'scaleX': case 'scaleY': target.setAttribute('data-option', -data.option); break; case 'reset': var cropboxdata = cropper.getCropBoxData(true); var canvasdata = cropper.getCanvasData(true); cropboxdata.left = canvasdata.left; cropboxdata.top = canvasdata.top; cropboxdata.width = canvasdata.width; cropboxdata.height = canvasdata.height; cropper.setCropBoxData(cropboxdata) $cropper_data.val(''); $cropper_message_container.hide(); $cropper_docrop.prop('checked', ''); break; case 'destroy': cropper = null; if (uploadedImageURL) { URL.revokeObjectURL(uploadedImageURL); uploadedImageURL = ''; image.src = originalImageURL; } break; } if (typeof result === 'object' && result !== cropper && input) { try { input.value = JSON.stringify(result); } catch (e) { console.log(e.message); } } } }; //make the image move on keyboard input document.body.onkeydown = function (event) { var e = event || window.event; if (e.target !== this || !cropper || this.scrollTop > 300) { return; } try { switch (e.keyCode) { case 65: case 37: //left (arrow left, a) e.preventDefault(); cropper.move(-1, 0); break; case 87: case 38: //up (arrow up, w) e.preventDefault(); cropper.move(0, -1); break; case 68: case 39: //right (arrow right, d) e.preventDefault(); cropper.move(1, 0); break; case 83: case 40: //down (arrow down, s) e.preventDefault(); cropper.move(0, 1); break; case 187: case 107: //zoom in (+ and numpad +) e.preventDefault(); cropper.zoom(0.1); break; case 189: case 109: //zoom out (- and numpad -) e.preventDefault(); cropper.zoom(-0.1); break; case 27: case 8: case 46: //reset (esc, del, backspace) e.preventDefault(); cropper.reset(); break; case 81: //rotate ccw (q) e.preventDefault(); cropper.rotate(-1); break; case 69: //rotate cw (e) e.preventDefault(); cropper.rotate(1); break; } } catch (err) { } }; } //makes the bootstrap tooltip work function bindTooltip() { if ($(window).width() < 768) return; var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { return new bootstrap.Tooltip(tooltipTriggerEl) }); }
When implementing the code in the actual project I needed it for, the line using (var newImage = new Image
kept giving the following error when compiling (Visible in Output Window in VS)
"The type 'SixLabors.ImageSharp.PixelFormats.Rgba32' cannot be used as type parameter 'TPixel' in the generic type or method 'Image'. There is no boxing conversion from 'SixLabors.ImageSharp.PixelFormats.Rgba32' to '?'."
While it does work in other projects. Solved it by creating a separate Class Library containing doCropperStuff()
and adding it to the project.