Search code examples
javascriptcssmatrixhtml5-canvastransform

Fill With White, Out Of Bounds Part Of Image - Crop Image With CSS Transformations on it


I have a DIV Container and an image inside of it. The image has CSS transformations like: translate, rotate, scale

The image can be dragged by a user (and scaled, rotated) in the parent div. Sometimes, parts of the image can be outside of the parent div.

I want to create a new image, identical in size with the original image (with no rotation applied, with no scaling applied), but the part of the image (that is rotated/scaled) from the div that is not visible, should be fully white in the final image.

Let me show what I want to obtain using an image: enter image description here

Original image used:

enter image description here

I tried various methods, but I fail to get the result I am looking for. Here is the code I came up with (with the help of ChatGPT also)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Dynamic Image Transformation and Cropping</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 20px;
        }
        .container {
            width: 300px; /* You can change this to any size */
            height: 300px; /* You can change this to any size */
            border: 2px solid #000;
            position: relative;
            overflow: hidden;
            margin-bottom: 20px;
        }
        .container img {
            width: 150px; /* You can change this to any size */
            height: 150px; /* You can change this to any size */
            /* Apply CSS transformations */
            transform: translate(-30px, -30px) rotate(45deg) scale(1.2);
            transform-origin: center center;
            position: absolute;
            top: 0;
            left: 0;
        }
        #buttons {
            margin-bottom: 20px;
        }
        #tempCanvases {
            display: flex;
            flex-wrap: wrap;
            gap: 10px;
        }
        #tempCanvases canvas {
            border: 1px solid #ccc;
        }
    </style>
</head>
<body>
    <h1>Dynamic Image Transformation and Cropping</h1>
    <div class="container">
        <img id="sourceImage" src="https://i.sstatic.net/M6v4Hvlp.jpg" alt="Source Image">
    </div>
    <div id="buttons">
        <button id="processButton">Process</button>
        <button id="downloadButton" disabled>Download</button>
    </div>
    <h2>Temporary Canvases (For Debugging)</h2>
    <div id="tempCanvases"></div>

    <script>
        // Wait for the DOM to load
        document.addEventListener('DOMContentLoaded', () => {
            const processButton = document.getElementById('processButton');
            const downloadButton = document.getElementById('downloadButton');
            const tempCanvasesDiv = document.getElementById('tempCanvases');
            const sourceImage = document.getElementById('sourceImage');
            let finalCanvas = null; // To store the final processed canvas

            processButton.addEventListener('click', () => {
                // Clear previous temporary canvases
                tempCanvasesDiv.innerHTML = '';
                finalCanvas = null;
                downloadButton.disabled = true;

                // Step 1: Get container and image dimensions
                const container = sourceImage.parentElement;
                const containerWidth = container.clientWidth;
                const containerHeight = container.clientHeight;

                const imageNaturalWidth = sourceImage.naturalWidth;
                const imageNaturalHeight = sourceImage.naturalHeight;

                const imageRenderedWidth = sourceImage.width;
                const imageRenderedHeight = sourceImage.height;

                console.log('Container Dimensions:', containerWidth, containerHeight);
                console.log('Image Natural Dimensions:', imageNaturalWidth, imageNaturalHeight);
                console.log('Image Rendered Dimensions:', imageRenderedWidth, imageRenderedHeight);

                // Step 2: Get computed styles of the image
                const style = window.getComputedStyle(sourceImage);
                const transform = style.transform;

                // If no transform is applied, set it to identity matrix
                const matrix = transform === 'none' ? new DOMMatrix() : new DOMMatrix(transform);

                // Extract transformation components
                const scaleX = matrix.a;
                const scaleY = matrix.d;
                const rotateRadians = Math.atan2(matrix.b, matrix.a);
                const rotateDegrees = rotateRadians * (180 / Math.PI);
                const translateX = matrix.e;
                const translateY = matrix.f;

                console.log('Extracted Transformations:');
                console.log('ScaleX:', scaleX);
                console.log('ScaleY:', scaleY);
                console.log('Rotate (degrees):', rotateDegrees);
                console.log('TranslateX:', translateX);
                console.log('TranslateY:', translateY);

                // Step 3: Create the first temporary canvas (container size) with transformations applied
                const tempCanvas1 = document.createElement('canvas');
                tempCanvas1.width = containerWidth;
                tempCanvas1.height = containerHeight;
                const ctx1 = tempCanvas1.getContext('2d');

                // Fill with white
                ctx1.fillStyle = '#FFFFFF';
                ctx1.fillRect(0, 0, tempCanvas1.width, tempCanvas1.height);

                // Calculate the center of the image
                const centerX = imageRenderedWidth / 2;
                const centerY = imageRenderedHeight / 2;

                // Apply transformations: translate, rotate, scale around the center
                ctx1.translate(translateX + centerX, translateY + centerY); // Move to the center
                ctx1.rotate(rotateRadians); // Apply rotation
                ctx1.scale(scaleX, scaleY); // Apply scaling
                ctx1.translate(-centerX, -centerY); // Move back

                // Draw the image
                ctx1.drawImage(sourceImage, 0, 0, imageRenderedWidth, imageRenderedHeight);

                // Append the first temporary canvas for debugging
                appendCanvas(tempCanvas1, 'Transformed Image');

                // Step 4: Create the second temporary canvas to revert transformations and crop
                const tempCanvas2 = document.createElement('canvas');
                tempCanvas2.width = containerWidth;
                tempCanvas2.height = containerHeight;
                const ctx2 = tempCanvas2.getContext('2d');

                // Fill with white
                ctx2.fillStyle = '#FFFFFF';
                ctx2.fillRect(0, 0, tempCanvas2.width, tempCanvas2.height);

                // To revert transformations, apply inverse transformations
                // Inverse scaling
                const invScaleX = 1 / scaleX;
                const invScaleY = 1 / scaleY;
                // Inverse rotation
                const invRotateRadians = -rotateRadians;

                ctx2.translate(-translateX - centerX, -translateY - centerY); // Reverse translation
                ctx2.translate(centerX, centerY); // Move to center
                ctx2.rotate(invRotateRadians); // Apply inverse rotation
                ctx2.scale(invScaleX, invScaleY); // Apply inverse scaling
                ctx2.translate(-centerX, -centerY); // Move back

                // Draw the image
                ctx2.drawImage(sourceImage, 0, 0, imageRenderedWidth, imageRenderedHeight);

                // Append the second temporary canvas for debugging
                appendCanvas(tempCanvas2, 'Reverted Transformations');

                // Step 5: Crop the image back to original size (natural image size)
                // Create final canvas based on the image's natural size
                finalCanvas = document.createElement('canvas');
                finalCanvas.width = imageNaturalWidth;
                finalCanvas.height = imageNaturalHeight;
                const ctxFinal = finalCanvas.getContext('2d');

                // Fill with white
                ctxFinal.fillStyle = '#FFFFFF';
                ctxFinal.fillRect(0, 0, finalCanvas.width, finalCanvas.height);

                // Calculate the scaling factor between rendered and natural size
                const scaleFactorX = imageNaturalWidth / imageRenderedWidth;
                const scaleFactorY = imageNaturalHeight / imageRenderedHeight;

                // Draw the reverted image onto the final canvas
                ctxFinal.drawImage(
                    tempCanvas2,
                    0, 0, containerWidth, containerHeight, // Source rectangle
                    0, 0, finalCanvas.width, finalCanvas.height // Destination rectangle
                );

                // Append the final canvas for debugging
                appendCanvas(finalCanvas, 'Final Cropped Image');

                // Enable the download button
                downloadButton.disabled = false;
            });

            downloadButton.addEventListener('click', () => {
                if (!finalCanvas) return;

                // Convert the final canvas to a data URL
                const dataURL = finalCanvas.toDataURL('image/png');

                // Create a temporary link to trigger download
                const link = document.createElement('a');
                link.href = dataURL;
                link.download = 'processed_image.png';
                document.body.appendChild(link);
                link.click();
                document.body.removeChild(link);
            });

            /**
             * Utility function to append a canvas with a label for debugging
             * @param {HTMLCanvasElement} canvas 
             * @param {string} label 
             */
            function appendCanvas(canvas, label) {
                const wrapper = document.createElement('div');
                const caption = document.createElement('p');
                caption.textContent = label;
                wrapper.appendChild(caption);
                wrapper.appendChild(canvas);
                tempCanvasesDiv.appendChild(wrapper);
            }
            });
        </script>
</body>
</html>


Solution

  • The 2D context setTransform method accepts DOMMatrix objects as arguments. Working entirely with such objects makes things easier, since once you've got it, you just need to apply its inverse() to come back to the initial transform.

    The tricky part might be to apply the transform-origin on this DOMMatrix object. There are several paths to get there, but basically it implies multiplying a first DOMMatrix that translates to the origin, then the actual matrix and finally a last one to reset the origin to the new top-left.

    Then you can use compositing to use a single canvas to render every steps.

    // Wait for the DOM to load
    document.addEventListener('DOMContentLoaded', () => {
      const processButton = document.getElementById('processButton');
      const downloadButton = document.getElementById('downloadButton');
      const tempCanvasesDiv = document.getElementById('tempCanvases');
      const sourceImage = document.getElementById('sourceImage');
      let finalCanvas = null;
    
      processButton.addEventListener('click', () => {
        // Clear previous temporary canvases
        tempCanvasesDiv.innerHTML = '';
        downloadButton.disabled = true;
    
        // Step 1: Get container and image dimensions
        const container = sourceImage.parentElement;
        const containerWidth = container.clientWidth;
        const containerHeight = container.clientHeight;
    
        const imageRenderedWidth = sourceImage.width;
        const imageRenderedHeight = sourceImage.height;
    
        // Step 2: Get computed styles of the image
        const style = window.getComputedStyle(sourceImage);
        const transform = style.transform;
    
        // Calculate the center of the image
        const centerX = imageRenderedWidth / 2;
        const centerY = imageRenderedHeight / 2;
    
        // If no transform is applied, set it to identity matrix
        const matrix = transform === 'none' ?
          new DOMMatrix() :
          new DOMMatrix(transform)
          // Apply the transform-origin
            .preMultiplySelf({ e: centerX, f: centerY })
            .multiply({ e: -centerX, f: -centerY });
    
    
        finalCanvas = document.createElement('canvas');
        finalCanvas.width = containerWidth;
        finalCanvas.height = containerHeight;
        const ctx1 = finalCanvas.getContext('2d');
    
    
        ctx1.setTransform(matrix);
        // Draw the image transformed
        ctx1.drawImage(sourceImage, 0, 0, imageRenderedWidth, imageRenderedHeight);
    
        // Draw again, using the inverse transform
        ctx1.setTransform(matrix.inverse());
        // Replace the previous content by the new content
        ctx1.globalCompositeOperation = "copy";
        ctx1.drawImage(ctx1.canvas, 0, 0);
    
        // Fill with white below the current drawing
        ctx1.fillStyle = '#FFFFFF';
        ctx1.globalCompositeOperation = "destination-over"; // Draw below
        ctx1.resetTransform(); // No transform
        ctx1.fillRect(0, 0, finalCanvas.width, finalCanvas.height);
    
        // Append the canvas for debugging
        appendCanvas(finalCanvas, 'Result');
    
        // Enable the download button
        downloadButton.disabled = false;
      });
    
      downloadButton.addEventListener('click', () => {
        if (!finalCanvas) return;
    
        // Convert the final canvas to a data URL
        const dataURL = finalCanvas.toDataURL('image/png');
    
        // Create a temporary link to trigger download
        const link = document.createElement('a');
        link.href = dataURL;
        link.download = 'processed_image.png';
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
      });
    
      /**
       * Utility function to append a canvas with a label for debugging
       * @param {HTMLCanvasElement} canvas 
       * @param {string} label 
       */
      function appendCanvas(canvas, label) {
        const wrapper = document.createElement('div');
        const caption = document.createElement('p');
        caption.textContent = label;
        wrapper.appendChild(caption);
        wrapper.appendChild(canvas);
        tempCanvasesDiv.appendChild(wrapper);
      }
    });
    body {
      font-family: Arial, sans-serif;
      margin: 20px;
    }
    
    .container {
      width: 300px;
      /* You can change this to any size */
      height: 300px;
      /* You can change this to any size */
      border: 2px solid #000;
      position: relative;
      overflow: hidden;
      margin-bottom: 20px;
    }
    
    .container img {
      width: 150px;
      /* You can change this to any size */
      height: 150px;
      /* You can change this to any size */
      /* Apply CSS transformations */
      transform: translate(-30px, -30px) rotate(45deg) scale(1.2);
      transform-origin: center center;
      position: absolute;
      top: 0;
      left: 0;
    }
    
    #buttons {
      margin-bottom: 20px;
    }
    
    #tempCanvases {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
    }
    
    #tempCanvases canvas {
      border: 1px solid #ccc;
    }
    <h1>Dynamic Image Transformation and Cropping</h1>
    <div class="container">
      <img id="sourceImage" src="https://i.sstatic.net/M6v4Hvlp.jpg" crossorigin alt="Source Image">
    </div>
    <div id="buttons">
      <button id="processButton">Process</button>
      <button id="downloadButton" disabled>Download</button>
    </div>
    <h2>Temporary Canvases (For Debugging)</h2>
    <div id="tempCanvases"></div>