X Split Image Merger (Pro)_EN

Automatically merge 4-part split images on X (Twitter) in strict 1-2-3-4 order, bypassing CORS issues.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         X Split Image Merger (Pro)_EN
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Automatically merge 4-part split images on X (Twitter) in strict 1-2-3-4 order, bypassing CORS issues.
// @author       https://x.com/Jiangshi_i
// @match        *://*.x.com/*
// @match        *://*.twitter.com/*
// @grant        GM_xmlhttpRequest
// @connect      pbs.twimg.com
// @icon         https://abs.twimg.com/favicons/twitter.3.ico
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // 1. Use MutationObserver instead of setInterval for better performance
    const observer = new MutationObserver(checkForImages);
    observer.observe(document.body, { childList: true, subtree: true });

    function checkForImages() {
        // Accurately locate the main tweet content to avoid grabbing profile pictures
        const posts = document.querySelectorAll('article[data-testid="tweet"]');

        posts.forEach(post => {
            // Must be an img inside tweetPhoto to ensure it's a media image in the post
            const images = post.querySelectorAll('[data-testid="tweetPhoto"] img');

            // If there are exactly 4 images and no button has been added yet
            if (images.length === 4 && !post.querySelector('.merge-btn-pro')) {
                addMergeButton(post, images);
            }
        });
    }

    function addMergeButton(post, images) {
        const buttonGroup = post.querySelector('[role="group"]'); // Usually the row with Retweet/Like buttons
        const button = document.createElement('button');
        button.textContent = 'Merge Images ⬇️';
        button.className = 'merge-btn-pro';
        // Simple UI styling
        button.style.cssText = `
            margin: 10px 0;
            padding: 8px 16px;
            background-color: #1d9bf0;
            color: white;
            border: none;
            border-radius: 9999px;
            font-weight: bold;
            cursor: pointer;
            font-size: 14px;
        `;

        button.onclick = (e) => {
            e.stopPropagation(); // Prevent accidentally clicking into the tweet
            button.textContent = 'Processing...';
            button.style.backgroundColor = '#536471';
            mergeImages(images, button);
        };

        // Insert the button below the images, above the interaction buttons
        if (buttonGroup) {
            buttonGroup.parentElement.insertBefore(button, buttonGroup);
        } else {
            post.appendChild(button);
        }
    }

    // Bypass CORS restrictions using Tampermonkey's built-in request to get Blob data
    function fetchImageAsBase64(url) {
        return new Promise((resolve, reject) => {
            // Replace the image quality parameter with 'orig' to get the highest resolution
            const highResUrl = url.replace(/&name=[^&]+/, '&name=orig');

            GM_xmlhttpRequest({
                method: 'GET',
                url: highResUrl,
                responseType: 'blob',
                onload: function(response) {
                    const reader = new FileReader();
                    reader.onloadend = () => resolve(reader.result);
                    reader.readAsDataURL(response.response);
                },
                onerror: reject
            });
        });
    }

    async function mergeImages(imageNodes, btnElement) {
        try {
            // Convert NodeList to an array. The order here is strictly: 0(Top-Left), 1(Top-Right), 2(Bottom-Left), 3(Bottom-Right)
            const imgUrls = Array.from(imageNodes).map(img => img.src);

            // 1. Fetch all image Base64 data concurrently (Promise.all ensures the returned array order matches imgUrls)
            const base64List = await Promise.all(imgUrls.map(fetchImageAsBase64));

            // 2. Convert Base64 to Image objects for Canvas drawing
            const loadedImages = await Promise.all(base64List.map(base64 => {
                return new Promise(resolve => {
                    const img = new Image();
                    img.onload = () => resolve(img);
                    img.src = base64;
                });
            }));

            // 3. Calculate canvas dimensions
            // Assuming all images have the same width, use the first image's width as the baseline
            const targetWidth = loadedImages[0].naturalWidth;
            let totalHeight = 0;

            // Calculate the proportional height for each segment based on the target width and accumulate total height
            loadedImages.forEach(img => {
                const scale = targetWidth / img.naturalWidth;
                totalHeight += img.naturalHeight * scale;
            });

            // 4. Create canvas and draw in strict order
            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');
            canvas.width = targetWidth;
            canvas.height = totalHeight;

            let currentY = 0;
            // Since loadedImages is in 1-2-3-4 order, iterating and drawing directly results in top-to-bottom order
            loadedImages.forEach(img => {
                const scale = targetWidth / img.naturalWidth;
                const drawHeight = img.naturalHeight * scale;
                ctx.drawImage(img, 0, currentY, targetWidth, drawHeight);
                currentY += drawHeight; // Move Y coordinate downwards
            });

            // 5. Trigger download
            downloadCanvas(canvas);

            // Restore button state
            btnElement.textContent = 'Merged ✅';
            btnElement.style.backgroundColor = '#00ba7c';
            setTimeout(() => {
                btnElement.textContent = 'Merge Images ⬇️';
                btnElement.style.backgroundColor = '#1d9bf0';
            }, 3000);

        } catch (error) {
            console.error("Failed to merge images:", error);
            btnElement.textContent = 'Failed ❌';
            btnElement.style.backgroundColor = '#f4212e';
            alert('Image download failed. This might be due to network issues or CORS restrictions. Press F12 to check the console for errors.');
        }
    }

    function downloadCanvas(canvas) {
        const link = document.createElement('a');
        link.download = `X_Merged_Image_${new Date().getTime()}.png`;
        link.href = canvas.toDataURL('image/png', 1.0);
        link.click();
    }
})();