miaowuduck / Rongguang Image Decoder Helper

// ==UserScript==
// @name         Rongguang Image Decoder Helper
// @name:zh-CN   荣光解码助手
// @namespace    https://yourname.example.com/
// @version      0.9
// @description  Decode images only when the topic title contains "荣光" (Rongguang), with smooth Web Worker processing and glassmorphism style buttons.
// @description:zh-CN  仅当标题出现“荣光”二字时启用,大图不卡顿 + 玻璃风按钮
// @match        *://shuiyuan.sjtu.edu.cn/t/topic/*
// @author       Your Name
// @license      MIT
// @homepageURL  https://github.com/yourname/yourrepo
// @supportURL   https://github.com/yourname/yourrepo/issues
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // === Check if the special keyword "荣光" exists in the topic title ===
    const specialElem = document.querySelector("#ember3 > div.drop-down-mode.d-header-wrap > header > div > div > div.two-rows.extra-info-wrapper > div > div > h1 > a > span");
    if (!specialElem || !specialElem.textContent.includes("荣光")) {
        console.log("荣光 keyword not found, Rongguang Image Decoder Helper is disabled.");
        return;
    }

    const processed = new WeakSet();
    const cacheMap = new WeakMap();

    /** === Worker script === **/
    const workerScript = `
        self.onmessage = async function(e) {
            const { buffer, width, height } = e.data;
            const blob = new Blob([buffer]);
            const imgBitmap = await createImageBitmap(blob);
            const canvas = new OffscreenCanvas(width || imgBitmap.width, height || imgBitmap.height);
            const ctx = canvas.getContext("2d");
            ctx.drawImage(imgBitmap, 0, 0);

            const imgdata = ctx.getImageData(0, 0, canvas.width, canvas.height);
            const imgdata2 = new ImageData(canvas.width, canvas.height);

            function gilbert2d(width, height) {
                const coordinates = [];
                if (width >= height) generate2d(0, 0, width, 0, 0, height, coordinates);
                else generate2d(0, 0, 0, height, width, 0, coordinates);
                return coordinates;
            }
            function generate2d(x, y, ax, ay, bx, by, coordinates) {
                const w = Math.abs(ax + ay), h = Math.abs(bx + by);
                const dax = Math.sign(ax), day = Math.sign(ay);
                const dbx = Math.sign(bx), dby = Math.sign(by);
                if (h === 1) { for (let i = 0; i < w; i++) { coordinates.push([x, y]); x += dax; y += day; } return; }
                if (w === 1) { for (let i = 0; i < h; i++) { coordinates.push([x, y]); x += dbx; y += dby; } return; }
                let ax2 = Math.floor(ax / 2), ay2 = Math.floor(ay / 2);
                let bx2 = Math.floor(bx / 2), by2 = Math.floor(by / 2);
                const w2 = Math.abs(ax2 + ay2), h2 = Math.abs(bx2 + by2);
                if (2 * w > 3 * h) {
                    if ((w2 % 2) && (w > 2)) { ax2 += dax; ay2 += day; }
                    generate2d(x, y, ax2, ay2, bx, by, coordinates);
                    generate2d(x + ax2, y + ay2, ax - ax2, ay - ay2, bx, by, coordinates);
                } else {
                    if ((h2 % 2) && (h > 2)) { bx2 += dbx; by2 += dby; }
                    generate2d(x, y, bx2, by2, ax2, ay2, coordinates);
                    generate2d(x + bx2, y + by2, ax, ay, bx - bx2, by - by2, coordinates);
                    generate2d(x + (ax - dax) + (bx2 - dbx), y + (ay - day) + (by2 - dby),
                               -bx2, -by2, -(ax - ax2), -(ay - ay2), coordinates);
                }
            }

            const curve = gilbert2d(canvas.width, canvas.height);
            const offset = Math.round((Math.sqrt(5) - 1) / 2 * canvas.width * canvas.height);

            let i = 0, total = canvas.width * canvas.height;
            const CHUNK_SIZE = 5000;

            function processChunk() {
                const end = Math.min(i + CHUNK_SIZE, total);
                for (; i < end; i++) {
                    const old_pos = curve[i];
                    const new_pos = curve[(i + offset) % total];
                    const old_p = 4 * (old_pos[0] + old_pos[1] * canvas.width);
                    const new_p = 4 * (new_pos[0] + new_pos[1] * canvas.width);
                    imgdata2.data.set(imgdata.data.slice(new_p, new_p + 4), old_p);
                }
                self.postMessage({ progress: 50 + Math.floor((i / total) * 50) });
                if (i < total) setTimeout(processChunk, 0);
                else {
                    ctx.putImageData(imgdata2, 0, 0);
                    canvas.convertToBlob({ type: "image/jpeg", quality: 0.95 })
                        .then(blob => {
                            const reader = new FileReader();
                            reader.onloadend = () => {
                                self.postMessage({ done: true, dataUrl: reader.result });
                            };
                            reader.readAsDataURL(blob);
                        });
                }
            }
            self.postMessage({ progress: 50 });
            processChunk();
        };
    `;

    function createWorker() {
        const blob = new Blob([workerScript], { type: "application/javascript" });
        return new Worker(URL.createObjectURL(blob));
    }

    function updateButtonProgress(btn, percent, text) {
        btn.textContent = text;
        btn.style.background = `linear-gradient(to right, rgba(40, 167, 69, 0.6) ${percent}%, rgba(255, 255, 255, 0.15) ${percent}%)`;
    }

    function downloadImageWithProgress(url, btn) {
        return new Promise((resolve, reject) => {
            const xhr = new XMLHttpRequest();
            xhr.open('GET', url, true);
            xhr.responseType = 'arraybuffer';
            xhr.onprogress = (event) => {
                if (event.lengthComputable) {
                    const percent = Math.floor((event.loaded / event.total) * 50);
                    updateButtonProgress(btn, percent, `Downloading… ${percent}%`);
                }
            };
            xhr.onload = () => {
                if (xhr.status === 200) {
                    updateButtonProgress(btn, 50, `Download complete, decoding…`);
                    resolve(xhr.response);
                } else reject(new Error(`Download failed`));
            };
            xhr.onerror = () => reject(new Error("Network error"));
            xhr.send();
        });
    }

    async function toggleDecryption(originalUrl, img, btn) {
        const cache = cacheMap.get(img);
        if (!cache) {
            btn.disabled = true;
            try {
                const arrayBuffer = await downloadImageWithProgress(originalUrl, btn);
                const testBlob = new Blob([arrayBuffer]);
                const testImg = new Image();
                testImg.src = URL.createObjectURL(testBlob);
                await new Promise(r => testImg.onload = r);

                const worker = createWorker();
                worker.postMessage({ buffer: arrayBuffer, width: testImg.width, height: testImg.height }, [arrayBuffer]);
                worker.onmessage = (e) => {
                    if (e.data.progress !== undefined) {
                        updateButtonProgress(btn, e.data.progress, `Decoding… ${e.data.progress}%`);
                    }
                    if (e.data.done) {
                        img.removeAttribute("srcset");
                        img.removeAttribute("loading");
                        img.src = e.data.dataUrl;
                        cacheMap.set(img, { decodedUrl: e.data.dataUrl, originalUrl, state: "decoded" });
                        btn.disabled = false;
                        btn.textContent = "Show Original";
                        btn.style.background = "rgba(255, 255, 255, 0.15)";
                    }
                };
            } catch (err) {
                btn.textContent = "Load Failed";
                btn.style.background = "rgba(255,0,0,0.5)";
            }
        } else {
            if (cache.state === "decoded") {
                img.src = cache.originalUrl;
                cache.state = "original";
                btn.textContent = "Show Decoded";
                btn.style.background = "rgba(40, 167, 69, 0.4)";
            } else {
                img.src = cache.decodedUrl;
                cache.state = "decoded";
                btn.textContent = "Show Original";
                btn.style.background = "rgba(255, 255, 255, 0.15)";
            }
        }
    }

    function attachButton(img) {
        const aTag = img.closest('a.lightbox');
        if (!aTag || !aTag.dataset.downloadHref || processed.has(img)) return;
        processed.add(img);

        const originalUrl = new URL(aTag.dataset.downloadHref, window.location.origin).href;
        const wrapper = document.createElement("div");
        wrapper.style.position = "relative";
        wrapper.style.display = "inline-block";
        aTag.parentNode.insertBefore(wrapper, aTag);
        wrapper.appendChild(aTag);

        const btn = document.createElement("button");
        btn.innerText = "Decode";
        btn.style.position = "absolute";
        btn.style.left = "0px";
        btn.style.top = "0px";
        btn.style.zIndex = "1000";
        // Glassmorphism style
        btn.style.minWidth = "140px";
        btn.style.height = "36px";
        btn.style.padding = "6px 16px";
        btn.style.borderRadius = "12px";
        btn.style.background = "rgba(255, 255, 255, 0.15)";
        btn.style.color = "#fff";
        btn.style.border = "1px solid rgba(255,255,255,0.3)";
        btn.style.backdropFilter = "blur(8px)";
        btn.style.webkitBackdropFilter = "blur(8px)";
        btn.style.boxShadow = "0 4px 12px rgba(0,0,0,0.3)";
        btn.style.fontWeight = "500";
        btn.style.fontSize = "14px";
        btn.style.letterSpacing = "0.5px";
        btn.style.cursor = "pointer";
        btn.style.whiteSpace = "nowrap";

        btn.onclick = () => toggleDecryption(originalUrl, img, btn);
        wrapper.appendChild(btn);
    }

    function processAllImages() {
        document.querySelectorAll('.cooked a.lightbox > img').forEach(attachButton);
    }

    window.addEventListener('load', processAllImages);
    const observer = new MutationObserver(m => {
        m.forEach(mu => mu.addedNodes.forEach(node => {
            if (node.nodeType === 1) {
                if (node.matches?.('.cooked a.lightbox > img')) attachButton(node);
                else node.querySelectorAll?.('.cooked a.lightbox > img').forEach(attachButton);
            }
        }));
    });
    observer.observe(document.body, { childList: true, subtree: true });

})();

/*
MIT License

Copyright (c) 2024 Your Name

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/