// ==UserScript== // @name Steam字符画生成器 v2.1 // @namespace https://steamcommunity.com/ // @version 2.1 // @description 图片转Unicode字符画;按UTF-8字节统计,适配Steam 1000字节留言限制 // @author Victor幻月 // @match *://steamcommunity.com/* // @match *://store.steampowered.com/* // @grant GM_xmlhttpRequest // @connect * // ==/UserScript== (function () { 'use strict'; // ==================== 配置 ==================== var CURATOR_URL = 'https://store.steampowered.com/curator/32969453/'; var STEAM_BYTE_LIMIT = 1000; // ==================== 字符常量 ==================== var BRAILLE_MAP = { '0,0': 0x01, '0,1': 0x02, '0,2': 0x04, '1,0': 0x08, '1,1': 0x10, '1,2': 0x20, '0,3': 0x40, '1,3': 0x80 }; var BAYER_2x4 = [[0, 4], [6, 2], [1, 5], [7, 3]]; function utf8ByteLength(str) { try { return new TextEncoder().encode(str).length; } catch (e) { return str.length * 3; } } // ==================== 图像算法 ==================== function gaussianBlur3(src, w, h) { var k = [1, 2, 1]; var tmp = new Float32Array(w * h); for (var y = 0; y < h; y++) { for (var x = 0; x < w; x++) { var s = 0, sum = 0; for (var i = -1; i <= 1; i++) { var xx = x + i; if (xx < 0 || xx >= w) continue; s += src[y * w + xx] * k[i + 1]; sum += k[i + 1]; } tmp[y * w + x] = s / sum; } } var out = new Float32Array(w * h); for (var y2 = 0; y2 < h; y2++) { for (var x2 = 0; x2 < w; x2++) { var s2 = 0, sum2 = 0; for (var j = -1; j <= 1; j++) { var yy = y2 + j; if (yy < 0 || yy >= h) continue; s2 += tmp[yy * w + x2] * k[j + 1]; sum2 += k[j + 1]; } out[y2 * w + x2] = s2 / sum2; } } return out; } function preprocess(gray, w, h, o) { var g = Float32Array.from(gray); if (o.denoise > 0) { var blurred = gaussianBlur3(g, w, h); var t = Math.min(1, o.denoise); for (var i = 0; i < g.length; i++) g[i] = g[i] * (1 - t) + blurred[i] * t; } var c = o.contrast, b = o.brightness, gm = o.gamma; for (var i2 = 0; i2 < g.length; i2++) { var v = (g[i2] - 128) * c + 128 + b; v = v < 0 ? 0 : v > 255 ? 255 : v; if (gm !== 1) v = 255 * Math.pow(v / 255, gm); g[i2] = v; } if (o.sharpen > 0) { var blurred2 = gaussianBlur3(g, w, h); for (var i3 = 0; i3 < g.length; i3++) { var v2 = g[i3] + (g[i3] - blurred2[i3]) * o.sharpen; g[i3] = v2 < 0 ? 0 : v2 > 255 ? 255 : v2; } } return g; } function adaptiveThreshold(gray, w, h, blockRatio, offset) { var integral = new Float64Array((w + 1) * (h + 1)); for (var y = 0; y < h; y++) { var rowSum = 0; for (var x = 0; x < w; x++) { rowSum += gray[y * w + x]; integral[(y + 1) * (w + 1) + (x + 1)] = integral[y * (w + 1) + (x + 1)] + rowSum; } } var bw = Math.max(1, Math.round(w * blockRatio)); var bh = Math.max(1, Math.round(h * blockRatio)); var bin = new Uint8Array(w * h); var W1 = w + 1; for (var y2 = 0; y2 < h; y2++) { var y1 = y2 - bh < 0 ? 0 : y2 - bh; var y3 = y2 + bh >= h ? h - 1 : y2 + bh; for (var x2 = 0; x2 < w; x2++) { var x1 = x2 - bw < 0 ? 0 : x2 - bw; var x3 = x2 + bw >= w ? w - 1 : x2 + bw; var area = (x3 - x1 + 1) * (y3 - y1 + 1); var sum = integral[(y3 + 1) * W1 + (x3 + 1)] - integral[y1 * W1 + (x3 + 1)] - integral[(y3 + 1) * W1 + x1] + integral[y1 * W1 + x1]; var mean = sum / area; bin[y2 * w + x2] = gray[y2 * w + x2] < mean * (1 - offset) ? 0 : 255; } } return bin; } function simpleThreshold(gray, t) { var bin = new Uint8Array(gray.length); for (var i = 0; i < gray.length; i++) bin[i] = gray[i] < t ? 0 : 255; return bin; } function fsDither(gray, w, h, t) { var work = Float32Array.from(gray); var bin = new Uint8Array(w * h); for (var y = 0; y < h; y++) { for (var x = 0; x < w; x++) { var i = y * w + x; var oldV = work[i]; var newV = oldV < t ? 0 : 255; bin[i] = newV; var err = oldV - newV; if (x + 1 < w) work[i + 1] += err * 7 / 16; if (y + 1 < h) { if (x > 0) work[i + w - 1] += err * 3 / 16; work[i + w] += err * 5 / 16; if (x + 1 < w) work[i + w + 1] += err * 1 / 16; } } } return bin; } function atkinsonDither(gray, w, h, t) { var work = Float32Array.from(gray); var bin = new Uint8Array(w * h); for (var y = 0; y < h; y++) { for (var x = 0; x < w; x++) { var i = y * w + x; var oldV = work[i]; var newV = oldV < t ? 0 : 255; bin[i] = newV; var err = (oldV - newV) / 8; if (x + 1 < w) work[i + 1] += err; if (x + 2 < w) work[i + 2] += err; if (y + 1 < h) { if (x > 0) work[i + w - 1] += err; work[i + w] += err; if (x + 1 < w) work[i + w + 1] += err; } if (y + 2 < h) work[i + 2 * w] += err; } } return bin; } function invertBin(bin) { for (var i = 0; i < bin.length; i++) bin[i] = 255 - bin[i]; } function blockHalftone(gray, w, h, invert) { var cols = Math.floor(w / 2); var rows = Math.floor(h / 4); var lines = []; for (var row = 0; row < rows; row++) { var line = ''; for (var col = 0; col < cols; col++) { var sum = 0; for (var dy = 0; dy < 4; dy++) for (var dx = 0; dx < 2; dx++) sum += gray[(row * 4 + dy) * w + (col * 2 + dx)]; var avg = sum / 8; var n = Math.round((255 - avg) / 255 * 8); if (invert) n = 8 - n; var code = 0; for (var dy2 = 0; dy2 < 4; dy2++) for (var dx2 = 0; dx2 < 2; dx2++) if (BAYER_2x4[dy2][dx2] < n) code |= BRAILLE_MAP[dx2 + ',' + dy2]; line += String.fromCharCode(0x2800 + code); } lines.push(line); } return lines.join('\n'); } function binaryToBraille(bin, w, h) { var cols = Math.floor(w / 2); var rows = Math.floor(h / 4); var lines = []; for (var row = 0; row < rows; row++) { var line = ''; for (var col = 0; col < cols; col++) { var code = 0; for (var dy = 0; dy < 4; dy++) for (var dx = 0; dx < 2; dx++) if (bin[(row * 4 + dy) * w + (col * 2 + dx)] === 0) code |= BRAILLE_MAP[dx + ',' + dy]; line += String.fromCharCode(0x2800 + code); } lines.push(line); } return lines.join('\n'); } function imageToBraille(img, o) { var pixelWidth = o.width * 2; var pixelHeight = Math.round(pixelWidth * (img.naturalHeight / img.naturalWidth) * o.heightScale); pixelHeight = Math.max(4, Math.ceil(pixelHeight / 4) * 4); var canvas = document.createElement('canvas'); canvas.width = pixelWidth; canvas.height = pixelHeight; var ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; var z = o.zoom; var sw = img.naturalWidth / z; var sh = img.naturalHeight / z; var sx = (img.naturalWidth - sw) / 2; var sy = (img.naturalHeight - sh) / 2; ctx.drawImage(img, sx, sy, sw, sh, 0, 0, pixelWidth, pixelHeight); var data = ctx.getImageData(0, 0, pixelWidth, pixelHeight).data; var gray = new Float32Array(pixelWidth * pixelHeight); for (var i = 0; i < gray.length; i++) { var p = i * 4; gray[i] = 0.299 * data[p] + 0.587 * data[p + 1] + 0.114 * data[p + 2]; } var g = preprocess(gray, pixelWidth, pixelHeight, o); if (o.mode === 'halftone') { return blockHalftone(g, pixelWidth, pixelHeight, o.invert); } var bin; if (o.mode === 'adaptive') bin = adaptiveThreshold(g, pixelWidth, pixelHeight, o.blockRatio, o.offset); else if (o.mode === 'fs') bin = fsDither(g, pixelWidth, pixelHeight, o.threshold); else if (o.mode === 'atkinson') bin = atkinsonDither(g, pixelWidth, pixelHeight, o.threshold); else bin = simpleThreshold(g, o.threshold); if (o.invert) invertBin(bin); return binaryToBraille(bin, pixelWidth, pixelHeight); } // ==================== UI 样式 ==================== var style = document.createElement('style'); style.textContent = [ '#bap-panel{position:fixed;bottom:20px;right:20px;z-index:99999;width:360px;', 'background:#1b2838;color:#c6d4df;border:1px solid #66c0f4;border-radius:6px;', 'font-family:Arial,sans-serif;font-size:13px;', 'box-shadow:0 4px 12px rgba(0,0,0,.5);overflow:hidden}', '#bap-header{padding:8px 12px;background:#2a475e;color:#fff;font-weight:bold;', 'cursor:pointer;user-select:none;display:flex;justify-content:space-between;align-items:center}', '#bap-header .badge{font-size:11px;color:#8bc34a;font-weight:normal}', '#bap-body{padding:12px;display:none;max-height:85vh;overflow-y:auto}', '#bap-suggest{padding:8px 12px;background:#2a475e;border-left:3px solid #ffc107;', 'font-size:11px;color:#c6d4df;display:flex;justify-content:space-between;align-items:center;gap:8px}', '#bap-suggest a{color:#66c0f4;text-decoration:underline}', '#bap-suggest .close{background:none;border:none;color:#8f98a0;cursor:pointer;font-size:14px;padding:0 4px}', '#bap-panel .row{display:flex;align-items:center;gap:6px;margin-bottom:6px}', '#bap-panel label.lb{font-size:12px;min-width:66px;flex-shrink:0}', '#bap-panel input[type=range]{flex:1;min-width:0}', '#bap-panel .val{font-size:11px;min-width:30px;text-align:right;color:#8f98a0}', '#bap-panel select,#bap-panel input[type=text],#bap-panel input[type=file]{', 'width:100%;box-sizing:border-box;padding:4px;background:#16202d;', 'border:1px solid #4c6b8a;color:#c6d4df;border-radius:3px;font-size:12px}', '#bap-panel select:disabled{opacity:.4}', '#bap-panel input[type=range]:disabled{opacity:.4}', '#bap-panel .btn{padding:6px;border:none;border-radius:3px;cursor:pointer;', 'font-weight:bold;font-size:12px}', '#bap-out{width:100%;height:190px;box-sizing:border-box;background:#16202d;', 'border:1px solid #4c6b8a;color:#c6d4df;white-space:pre;overflow:auto;', 'padding:4px;resize:vertical;font-family:Consolas,Courier New,monospace}', '#bap-panel details{margin:6px 0;border-top:1px solid #2a475e;padding-top:6px}', '#bap-panel summary{cursor:pointer;font-size:12px;color:#66c0f4;margin-bottom:6px}', '#bap-panel .hint{font-size:10px;color:#556677;margin-top:4px}', '#bap-panel .status{font-size:11px;color:#8f98a0;margin:4px 0;min-height:14px}', '#bap-panel .status.ok{color:#8bc34a}', '#bap-panel .status.warn{color:#ffc107}', '#bap-panel .status.err{color:#ff6b6b}', '#bap-panel .btn-primary{background:#66c0f4;color:#1b2838}', '#bap-panel .btn-secondary{background:#4c6b8a;color:#fff}' ].join(''); document.head.appendChild(style); // ==================== 面板骨架 ==================== var panel = document.createElement('div'); panel.id = 'bap-panel'; panel.innerHTML = [ '
', ' ▶ Steam字符画生成器 v2.1', '
', '
', ' 💡 如果觉得好用,欢迎关注 我们的鉴赏家', ' ', '
', '
', ' ', ' ', '
', '
', ' ', '
', '
', ' ', ' 40
', '
', ' ', ' 1.10
', '
', ' ', ' 0.6
', '
', '
', ' 高级设置', '
', ' ', ' 128
', '
', ' ', ' 0.08
', '
', ' ', ' 0.15
', '
', ' ', ' 0
', '
', ' ', ' 1.00
', '
', ' ', ' 0.0
', '
', ' ', ' 1.0x
', '
', ' ', ' 1.00
', '
纵向修正用于补偿字符在字体中的宽高比偏差;若在 Steam 中显得被纵向拉伸,可调小(如 0.85)。
', '
', '
', ' ', ' ', '
', '
', ' ', '
⚠️ Steam 留言限制 UTF-8 字节数(约 1000 字节),每个字符占 3 字节。宽度 40 时约 8 行内安全。
', '
' ].join(''); document.body.appendChild(panel); var $ = function (id) { return document.getElementById(id); }; var currentImage = null; // ==================== 建议横幅(只隐藏本次,不记入 localStorage) ==================== $('bap-suggest-close').addEventListener('click', function () { var s = $('bap-suggest'); if (s) s.style.display = 'none'; }); // ==================== 标题栏折叠 ==================== $('bap-header').addEventListener('click', function () { var body = $('bap-body'); var title = $('bap-title'); if (body.style.display === 'none' || body.style.display === '') { body.style.display = 'block'; title.textContent = '▼ Steam字符画生成器 v2.1'; } else { body.style.display = 'none'; title.textContent = '▶ Steam字符画生成器 v2.1'; } }); // ==================== 参数 ==================== function getOptions() { return { width: +$('bap-width').value, mode: $('bap-mode').value, threshold: +$('bap-threshold').value, blockRatio: +$('bap-blockRatio').value / 100, offset: +$('bap-offset').value / 100, contrast: +$('bap-contrast').value / 100, brightness: +$('bap-brightness').value, gamma: +$('bap-gamma').value / 100, sharpen: +$('bap-sharpen').value / 100, denoise: +$('bap-denoise').value / 100, zoom: +$('bap-zoom').value / 100, heightScale: +$('bap-heightScale').value / 100, invert: $('bap-invert').checked }; } // ==================== 滑块显示 ==================== var sliders = [ ['bap-width', 'bap-width-v', function (v) { return v; }], ['bap-contrast', 'bap-contrast-v', function (v) { return (v / 100).toFixed(2); }], ['bap-sharpen', 'bap-sharpen-v', function (v) { return (v / 100).toFixed(1); }], ['bap-threshold', 'bap-threshold-v', function (v) { return v; }], ['bap-blockRatio', 'bap-blockRatio-v', function (v) { return (v / 100).toFixed(2); }], ['bap-offset', 'bap-offset-v', function (v) { return (v / 100).toFixed(2); }], ['bap-brightness', 'bap-brightness-v', function (v) { return v; }], ['bap-gamma', 'bap-gamma-v', function (v) { return (v / 100).toFixed(2); }], ['bap-denoise', 'bap-denoise-v', function (v) { return (v / 100).toFixed(1); }], ['bap-zoom', 'bap-zoom-v', function (v) { return (v / 100).toFixed(1) + 'x'; }], ['bap-heightScale', 'bap-heightScale-v', function (v) { return (v / 100).toFixed(2); }] ]; sliders.forEach(function (item) { var s = $(item[0]), v = $(item[1]); if (!s || !v) return; var upd = function () { v.textContent = item[2](+s.value); }; s.addEventListener('input', upd); upd(); }); // ==================== 模式控件禁用 ==================== function updateEnabled() { var mode = $('bap-mode').value; $('bap-threshold').disabled = (mode === 'adaptive' || mode === 'halftone'); $('bap-blockRatio').disabled = (mode !== 'adaptive'); $('bap-offset').disabled = (mode !== 'adaptive'); } $('bap-mode').addEventListener('change', function () { updateEnabled(); generate(); }); updateEnabled(); // ==================== 图片加载 ==================== function loadImageFromFile(file) { return new Promise(function (resolve, reject) { var img = new Image(); img.onload = function () { resolve(img); }; img.onerror = function () { reject(new Error('图片加载失败')); }; img.src = URL.createObjectURL(file); }); } function loadImageFromURL(url) { return new Promise(function (resolve, reject) { var img = new Image(); img.crossOrigin = 'anonymous'; img.onload = function () { resolve(img); }; img.onerror = function () { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'blob', onload: function (res) { if (res.status !== 200) { reject(new Error('HTTP ' + res.status)); return; } var img2 = new Image(); img2.onload = function () { resolve(img2); }; img2.onerror = function () { reject(new Error('Blob 图片加载失败')); }; img2.src = URL.createObjectURL(res.response); }, onerror: function () { reject(new Error('网络请求失败')); } }); }; img.src = url; }); } // ==================== 状态 ==================== function setStatus(text, cls) { var el = $('bap-status'); if (!el) return; el.textContent = text; el.className = 'status' + (cls ? ' ' + cls : ''); } // ==================== 生成 ==================== async function generate() { if (!currentImage) { setStatus('请先选择图片或输入图片 URL'); return; } setStatus('生成中...'); await new Promise(function (r) { setTimeout(r, 10); }); try { var o = getOptions(); var result = imageToBraille(currentImage, o); var fs = Math.max(1.5, Math.min(8, 320 / o.width)); var out = $('bap-out'); out.style.fontSize = fs + 'px'; out.style.lineHeight = fs + 'px'; out.value = result; var rows = result.split('\n').length; var charCount = result.length; var byteCount = utf8ByteLength(result); var ratio = byteCount / STEAM_BYTE_LIMIT; var msg = o.width + '列 × ' + rows + '行 · ' + charCount + ' 字符 / ' + byteCount + ' 字节(上限 ' + STEAM_BYTE_LIMIT + ')'; var cls = 'ok'; if (ratio > 1) { cls = 'err'; var safeWidth = Math.max(8, Math.floor(o.width / Math.sqrt(ratio))); msg += ' · ⚠️ 超出限制,建议宽度 ≤ ' + safeWidth; } else if (ratio > 0.9) { cls = 'warn'; msg += ' · ⚠️ 接近上限'; } else { msg += ' · ✓ 可发布'; } setStatus(msg, cls); } catch (err) { setStatus('生成失败:' + err.message, 'err'); console.error(err); } } // ==================== 事件 ==================== $('bap-file').addEventListener('change', async function (e) { var f = e.target.files[0]; if (!f) return; try { currentImage = await loadImageFromFile(f); setStatus('已加载:' + f.name); generate(); } catch (err) { setStatus('加载失败:' + err.message, 'err'); } }); $('bap-url').addEventListener('change', async function (e) { var url = e.target.value.trim(); if (!url) return; try { setStatus('加载图片中...'); currentImage = await loadImageFromURL(url); setStatus('图片已加载'); generate(); } catch (err) { setStatus('加载失败:' + err.message, 'err'); } }); $('bap-generate').addEventListener('click', generate); $('bap-copy').addEventListener('click', async function () { var text = $('bap-out').value; if (!text) { setStatus('没有可复制的内容'); return; } var byteCount = utf8ByteLength(text); try { await navigator.clipboard.writeText(text); if (byteCount > STEAM_BYTE_LIMIT) { setStatus('已复制,但 ' + byteCount + ' 字节超出上限,粘贴后可能报错', 'err'); } else { setStatus('已复制到剪贴板 ✓(' + byteCount + ' 字节)', 'ok'); setTimeout(function () { setStatus(''); }, 2500); } } catch (err) { $('bap-out').select(); document.execCommand('copy'); setStatus('已复制(回退方式)', byteCount > STEAM_BYTE_LIMIT ? 'err' : 'ok'); } }); var timer; function autoGen() { clearTimeout(timer); timer = setTimeout(generate, 250); } ['bap-width', 'bap-contrast', 'bap-sharpen', 'bap-threshold', 'bap-blockRatio', 'bap-offset', 'bap-brightness', 'bap-gamma', 'bap-denoise', 'bap-zoom', 'bap-heightScale'].forEach(function (id) { $(id).addEventListener('input', autoGen); }); $('bap-invert').addEventListener('change', generate); // ==================== 启动 ==================== console.log('[Steam字符画生成器 2.1] 已加载……'); })();