OpenWebUI Force HTML Image Renderer

OpenWebUI 强制 HTML 图片渲染, 识别 <img> 标签,基于 OpenWebUI v0.6.10 开发。使用前请更改 @match 后面的 URL 以使脚本在你的 OpenWebUI URL中工作。

От 25.05.2025. Виж последната версия.

// ==UserScript==
// @name         OpenWebUI Force HTML Image Renderer
// @version      1.1.0
// @description  OpenWebUI 强制 HTML 图片渲染, 识别 <img> 标签,基于 OpenWebUI v0.6.10 开发。使用前请更改 @match 后面的 URL 以使脚本在你的 OpenWebUI URL中工作。
// @author       B3000Kcn
// @match        https://your.openwebui.url/*
// @run-at       document-idle
// @license      MIT
// @namespace https://greasyfork.org/users/1474401
// ==/UserScript==

(function() {
    'use strict';

    const SCRIPT_VERSION = '1.0.0-dom-hybrid.2';
    const TARGET_DIV_DEPTH = 22;
    const PROCESSED_CLASS = `html-img-rendered-v1-0-0`; // 与原v1.0.0保持一致,方便升级或识别

    const ALLOWED_CONTAINER_TAGS = ['P', 'SPAN', 'PRE', 'CODE', 'LI', 'BLOCKQUOTE', 'FIGCAPTION', 'LABEL', 'SMALL', 'STRONG', 'EM', 'B', 'I', 'U', 'SUB', 'SUP', 'MARK', 'DEL', 'INS', 'TD', 'TH', 'DT', 'DD']; // v1.0.0中的 ALLOWED_INNERHTML_MOD_TAGS
    const TAGS_TO_SKIP_PROCESSING = ['SCRIPT', 'STYLE', 'HEAD', 'META', 'LINK', 'TITLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'BUTTON', 'SELECT', 'FORM', 'IFRAME', 'VIDEO', 'AUDIO', 'CANVAS', 'SVG', 'IMG'];

    const escapedImgTagRegex = /&lt;img\s+(.*?)&gt;/gi;
    const directImgTagRegex = /<img\s+([^>]*)>/gi;

    function getElementDepth(element) {
        let depth = 0; let el = element;
        while (el) { depth++; el = el.parentElement; }
        return depth;
    }

    // 稍微增强的 getAttributeValue,尝试处理有无引号的属性值
    function getAttributeValue(attributesString, attributeName) {
        if (!attributesString || !attributeName) return null;
        try {
            // 1. 尝试匹配带引号的: name="value" or name='value'
            let regex = new RegExp(`${attributeName}\\s*=\\s*(["'])(.*?)\\1`, 'i');
            let match = attributesString.match(regex);
            if (match && match[2] !== undefined) return match[2];

            // 2. 尝试匹配不带引号的简单值 (例如 width=100)
            // 这个正则假设值不包含空格、引号、> 等,适用于简单的数字或标识符
            regex = new RegExp(`${attributeName}\\s*=\\s*([^\\s"'<>]+)`, 'i');
            match = attributesString.match(regex);
            if (match && match[1] !== undefined) return match[1];

            return null;
        } catch (e) {
            console.error(`${SCRIPT_VERSION} Error in getAttributeValue for ${attributeName}:`, e);
            return null;
        }
    }

    // 增强版的 processImageTag,能从 style 属性提取 width/height
    function processImageTag(match, attributesStringOriginal, isEscaped) {
        if (typeof match !== 'string' || match.length === 0) {
            return match;
        }

        // 避免重复处理已由本脚本渲染的图片(检查原始匹配串中是否包含标记)
        if (!isEscaped && match.startsWith("<img") && match.includes('data-force-rendered="true"')) {
            return match;
        }
        // (可以考虑添加对 <p class="userscript-image-paragraph"... 的检查,如果需要更严格避免嵌套)

        const attributesString = attributesStringOriginal.trim();
        const src = getAttributeValue(attributesString, 'src');

        if (!src || (typeof src === 'string' && src.toLowerCase().startsWith('javascript:'))) {
            // console.warn(`${SCRIPT_VERSION} Warning: Invalid or blocked src: ${src}`);
            return match; // 返回原始匹配,不处理
        }

        let width = getAttributeValue(attributesString, 'width');
        let height = getAttributeValue(attributesString, 'height');
        const styleString = getAttributeValue(attributesString, 'style');
        const alt = getAttributeValue(attributesString, 'alt');

        // 如果无法从 width/height 属性获取,尝试从 style 属性解析
        if ((!width || String(width).trim() === '') && styleString) {
            const styleWidthMatch = styleString.match(/width\s*:\s*([^;!]+)/i); // !important 可能会干扰,简单排除;
            if (styleWidthMatch && styleWidthMatch[1]) {
                width = styleWidthMatch[1].trim();
            }
        }
        if ((!height || String(height).trim() === '') && styleString) {
            const styleHeightMatch = styleString.match(/height\s*:\s*([^;!]+)/i);
            if (styleHeightMatch && styleHeightMatch[1]) {
                height = styleHeightMatch[1].trim();
            }
        }

        const img = document.createElement('img'); // 临时创建以构建HTML字符串
        img.src = src;
        img.alt = alt ? alt : `用户HTML图片 (${isEscaped ? '转义' : '直接'}) v${SCRIPT_VERSION}`;
        img.setAttribute('data-force-rendered', 'true'); // 标记为已处理

        if (width && String(width).trim() !== '') {
            const wStr = String(width).trim();
            if (wStr.includes('%') || wStr.match(/^[0-9.]+(em|rem|px|vw|vh|pt|cm|mm|in|auto)$/i)) {
                img.style.width = wStr;
            } else { // 假设是纯数字,视为像素 (HTML width attribute)
                img.setAttribute('width', wStr.replace(/px$/i, ''));
            }
        }
        if (height && String(height).trim() !== '') {
            const hStr = String(height).trim();
            if (hStr.includes('%') || hStr.match(/^[0-9.]+(em|rem|px|vw|vh|pt|cm|mm|in|auto)$/i)) {
                img.style.height = hStr;
            } else {
                img.setAttribute('height', hStr.replace(/px$/i, ''));
            }
        }

        img.style.maxWidth = '100%';
        img.style.display = 'block';
        // img.onerror an actual DOM element needs this, not the temp one for outerHTML.
        // If needed, the final DOM element should have this attached. For now, this script doesn't do it.

        const imgHTML = img.outerHTML;
        const pWrappedHTML = `<p class="userscript-image-paragraph" style="margin: 0.5em 0; line-height: normal;">${imgHTML}</p>`;
        return pWrappedHTML;
    }

    // 改造后的 renderImagesInElement 函数
    function renderImagesInElement(element, forcedByObserver = false) {
        if (!element || typeof element.classList === 'undefined' || TAGS_TO_SKIP_PROCESSING.includes(element.tagName.toUpperCase()) || element.isContentEditable) {
            return;
        }

        const currentDepth = getElementDepth(element);
        if (currentDepth !== TARGET_DIV_DEPTH) {
            return;
        }

        const isAllowedTypeAtTargetDepth = (element.tagName.toUpperCase() === 'DIV') || ALLOWED_CONTAINER_TAGS.includes(element.tagName.toUpperCase());
        if (!isAllowedTypeAtTargetDepth) {
            return;
        }

        if (element.classList.contains(PROCESSED_CLASS)) {
            if (forcedByObserver) {
                element.classList.remove(PROCESSED_CLASS);
            } else {
                return;
            }
        }

        let madeChangeOverall = false;
        const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
        const textNodesToModify = [];

        let textNodeWalkerCurrentNode;
        while (textNodeWalkerCurrentNode = walker.nextNode()) {
            let parentCheck = textNodeWalkerCurrentNode.parentElement;
            let inSkippedSubtree = false;
            while (parentCheck && parentCheck !== element.parentElement) {
                if (parentCheck.tagName && TAGS_TO_SKIP_PROCESSING.includes(parentCheck.tagName.toUpperCase())) {
                    inSkippedSubtree = true;
                    break;
                }
                if (parentCheck === element) break;
                parentCheck = parentCheck.parentElement;
            }

            if (!inSkippedSubtree && textNodeWalkerCurrentNode.nodeValue &&
                (textNodeWalkerCurrentNode.nodeValue.includes('&lt;img') || textNodeWalkerCurrentNode.nodeValue.includes('<img'))) {
                textNodesToModify.push(textNodeWalkerCurrentNode);
            }
        }

        for (const textNode of textNodesToModify) {
            if (!textNode.parentNode) continue;

            const originalNodeValue = textNode.nodeValue;
            let newTextContent = originalNodeValue;
            let currentTextNodeMadeChange = false;

            const createReplacer = (isEscaped) => (match, attrs) => {
                const replacement = processImageTag(match, attrs, isEscaped);
                if (replacement !== match) {
                    currentTextNodeMadeChange = true;
                }
                return replacement;
            };

            if (newTextContent.includes('&lt;img')) {
                newTextContent = newTextContent.replace(escapedImgTagRegex, createReplacer(true));
            }
            if (newTextContent.includes('<img')) { // Check again in case escaped replacement introduced <img
                newTextContent = newTextContent.replace(directImgTagRegex, createReplacer(false));
            }

            if (currentTextNodeMadeChange) {
                madeChangeOverall = true;
                const fragment = document.createDocumentFragment();
                const tempDiv = document.createElement('div');
                tempDiv.innerHTML = newTextContent;

                while (tempDiv.firstChild) {
                    fragment.appendChild(tempDiv.firstChild);
                }
                try {
                    textNode.parentNode.replaceChild(fragment, textNode);
                } catch (e) {
                    console.error(`${SCRIPT_VERSION} Error replacing text node:`, e, textNode.parentNode);
                }
            }
        }

        if (madeChangeOverall) {
            if (!element.classList.contains(PROCESSED_CLASS)) {
                element.classList.add(PROCESSED_CLASS);
            }
        } else if (!element.classList.contains(PROCESSED_CLASS) && textNodesToModify.length > 0) {
            element.classList.add(PROCESSED_CLASS);
        }
    }

    // MutationObserver 和初始化逻辑 (与v1.0.0结构一致, 调用新的renderImagesInElement)
    const observerCallback = (mutationsList) => {
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        renderImagesInElement(node, true);
                        if (typeof node.querySelectorAll === 'function') {
                            // 选择器应与 initialScan 一致或更通用地覆盖可能包含目标元素的子元素
                            // DIV 本身会通过直接调用 renderImagesInElement(node, true) 检查深度
                            // 其他允许的容器类型也需要被检查
                            const relevantChildSelectors = ALLOWED_CONTAINER_TAGS.join(',') + ',DIV';
                            node.querySelectorAll(relevantChildSelectors).forEach(child => {
                                if (child.nodeType === Node.ELEMENT_NODE) renderImagesInElement(child, true);
                            });
                        }
                    } else if (node.nodeType === Node.TEXT_NODE && node.parentElement) {
                        renderImagesInElement(node.parentElement, true);
                    }
                });
            } else if (mutation.type === 'characterData') {
                if (mutation.target && mutation.target.parentElement) {
                    renderImagesInElement(mutation.target.parentElement, true);
                }
            }
        }
    };

    const observer = new MutationObserver(observerCallback);
    const observerConfig = { childList: true, subtree: true, characterData: true };

    function initialScan() {
        // 初始扫描时,选择所有可能是目标深度容器的元素类型
        const allRelevantSelectors = ALLOWED_CONTAINER_TAGS.join(',') + ',DIV';
        document.querySelectorAll(allRelevantSelectors).forEach(el => {
            // 确保元素在DOM中并且可见 (offsetParent !== null 是一个简单的检查)
            if (el.offsetParent !== null || document.body.contains(el)) {
                renderImagesInElement(el, false);
            }
        });
    }

    function activateScript() {
        // console.log(`OpenWebUI 图片强制渲染脚本 v${SCRIPT_VERSION} (DOM混合模式) 准备启动...`);
        setTimeout(() => {
            initialScan();
            observer.observe(document.body, observerConfig);
            // console.log(`OpenWebUI 图片强制渲染脚本 v${SCRIPT_VERSION} (DOM混合模式) 已启动。`);
        }, 1500); // 延迟时间可根据页面加载情况调整
    }

    if (document.readyState === 'complete' || document.readyState === 'interactive') {
        activateScript();
    } else {
        document.addEventListener('DOMContentLoaded', activateScript, { once: true });
    }

})();