diff --git a/.github/workflows/apply-picviewer-qr.yml b/.github/workflows/apply-picviewer-qr.yml new file mode 100644 index 00000000000..1bea9586f20 --- /dev/null +++ b/.github/workflows/apply-picviewer-qr.yml @@ -0,0 +1,57 @@ +name: Apply Picviewer QR patch + +on: + push: + branches: + - feature/picviewer-qr-scan + paths: + - "Picviewer CE+/tools/apply-qr-patch.js" + - ".github/workflows/apply-picviewer-qr.yml" + workflow_dispatch: + +permissions: + contents: write + +jobs: + patch-and-verify: + runs-on: ubuntu-latest + steps: + - name: Check out feature branch + uses: actions/checkout@v4 + with: + ref: feature/picviewer-qr-scan + fetch-depth: 0 + + - name: Apply QR integration + run: node "Picviewer CE+/tools/apply-qr-patch.js" + + - name: Verify JavaScript syntax + run: node --check "Picviewer CE+/Picviewer CE+.user.js" + + - name: Verify patch markers + shell: bash + run: | + set -euo pipefail + file="Picviewer CE+/Picviewer CE+.user.js" + grep -Fq "// @resource jsQR https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js" "$file" + grep -Fq "// Picviewer QR integration start" "$file" + grep -Fq "case 'qr':" "$file" + grep -Fq "pv-float-bar-button-qr" "$file" + grep -Fq "actual,current,qr,gallery,magnifier,download,stitch" "$file" + test "$(grep -Fc "// Picviewer QR integration start" "$file")" -eq 1 + + - name: Verify clean diff + run: git diff --check + + - name: Commit generated integration + shell: bash + run: | + if git diff --quiet; then + echo "QR integration is already up to date." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add "Picviewer CE+/Picviewer CE+.user.js" + git commit -m "feat(picviewer): add QR code scanner" + git push origin HEAD:feature/picviewer-qr-scan diff --git a/Picviewer CE+/Picviewer CE+.user.js b/Picviewer CE+/Picviewer CE+.user.js index 440b3e10f70..e4ab4ed6c65 100644 --- a/Picviewer CE+/Picviewer CE+.user.js +++ b/Picviewer CE+/Picviewer CE+.user.js @@ -12,7 +12,7 @@ // @description:ja 画像を強力に閲覧できるツール。ポップアップ表示、拡大・縮小、回転、一括保存などの機能を自動で実行できます // @description:pt-BR Poderosa ferramenta de visualização de imagens on-line, que pode pop-up/dimensionar/girar/salvar em lote imagens automaticamente // @description:ru Мощный онлайн-инструмент для просмотра изображений, который может автоматически отображать/масштабировать/вращать/пакетно сохранять изображения -// @version 2026.2.6.1 +// @version 2026.2.6.2 // @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAMAAADXqc3KAAAAV1BMVEUAAAD////29vbKysoqKioiIiKysrKhoaGTk5N9fX3z8/Pv7+/r6+vk5OTb29vOzs6Ojo5UVFQzMzMZGRkREREMDAy4uLisrKylpaV4eHhkZGRPT08/Pz/IfxjQAAAAgklEQVQoz53RRw7DIBBAUb5pxr2m3/+ckfDImwyJlL9DDzQgDIUMRu1vWOxTBdeM+onApENF0qHjpkOk2VTwLVEF40Kbfj1wK8AVu2pQA1aBBYDHJ1wy9Cf4cXD5chzNAvsAnc8TjoLAhIzsBao9w1rlVTIvkOYMd9nm6xPi168t9AYkbANdajpjcwAAAABJRU5ErkJggg== // @namespace https://github.com/hoothin/UserScripts // @homepage https://pv.hoothin.com/ @@ -31,6 +31,7 @@ // @grant GM_addStyle // @grant GM_openInTab // @grant GM_setClipboard +// @grant GM_getResourceText // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_notification @@ -48,6 +49,7 @@ // @require https://update.greasyfork.org/scripts/6158/23710/GM_config%20CN.js // @require https://update.greasyfork.org/scripts/438080/1738227/pvcep_rules.js // @require https://update.greasyfork.org/scripts/440698/1740314/pvcep_lang.js +// @resource jsQR https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js // @match *://*/* // @exclude http://www.toodledo.com/tasks/* // @exclude http*://maps.google.com*/* @@ -12943,7 +12945,7 @@ ImgOps | https://imgops.com/#b#`; // 默认设置,请到设置界面修改 prefs={ floatBar:{//浮动工具栏相关设置. - butonOrder:['actual','current','gallery','magnifier','download','stitch'],//按钮排列顺序'actual'(实际的图片),'current'(当前显示的图片),'magnifier'(放大镜观察),'gallery'(图集),'search'(搜索原图) + butonOrder:['actual','current','qr','gallery','magnifier','download','stitch'],//按钮排列顺序'actual'(实际的图片),'current'(当前显示的图片),'magnifier'(放大镜观察),'gallery'(图集),'search'(搜索原图) additionalFeature: 'open', invertAdditionalFeature: false, listenBg:true,//监听背景图 @@ -13151,6 +13153,7 @@ ImgOps | https://imgops.com/#b#`; gallery:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAMAAADXqc3KAAAAP1BMVEUAAAD///94eHgZGRn39/fz8/POzs6xsbGSkpJ9fX1UVFQpKSnb29vJycmmpqafn5+UlJSNjY0/Pz8hISENDQ2fWpEMAAAAcUlEQVQoz43SWQrEIBRE0Xe7M8/T/tcaNSKCKUh95nCDiIaYYa9zUDQRiiaCalANqkE0n6BuBLArWBTUAsa2/3yqfwYdzAl+GeCWAN9YM7nvo4chgoXmgjP8CdoEvqmA/oCQRHgat2p7YM2gvHb9GMRu7acCGLmlyNoAAAAASUVORK5CYII=', search:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAMAAADXqc3KAAAAXVBMVEUAAAD///+MjIwmJibNzc2UlJTd3d2lpaUKCgrT09O/v78YGBjj4+MvLy8PDw/IyMh5eXn5+fn29vby8vLo6OjDw8O7u7u3t7ewsLCcnJx1dXVubm4+Pj43NzdkZGStc/JSAAAA4ElEQVQoz52RWW7DMAxE9bR635fYcXr/Y5aW7TYIGqDI/EjUAzRDUvFGCvWn/gHMOnmfNeYFJLonqnPVM8hrIA3B7of5BXkK47bXWwbe/ACp3OWqwSYnaI73+zStSST6AKYjE/sbQCZkpi0j0HSlUl/gPdwleM8SgSfIRzd8laRkcl0oIihYIkiVqhnl6hgicPRGrMHW0Ej4gXCYt8xiPoIw6TtANL8CVtpanSu1xvARJHYnpxpIq6tzU8D8UKIywHCNZCcpUDuXteDL57HngVMhf1nUQ9uisG47y492/kbfyJQHZ5yu1AMAAAAASUVORK5CYII=', download:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAMAAADXqc3KAAAASFBMVEUAAAD///86Ojq8vLxXV1ciIiKcnJympqZjY2MxMTHc3NywsLBDQ0NPT08VFRV5eXn39/fw8PDZ2dnV1dXKysqUlJTl5eUNDQ1EnTQhAAAAtUlEQVQoz33QWRKDIBAE0Gl2AXFP7n/TDKIEk5j+wKp+ZQ0D4SYE+pkDkrMe3rr0ATEYpUkrE+IFouyppJexgRR6IQQAPodlAqeAMyRoByIyjo8DrGpA2Td43YD2FfJHokR2hOsf8uy1v87oZOnrjHorFu7rreoexKLzhiFlqJsPxJL7dcZagWU532oG0ABNzj7y6wpwVAOgJ8AztgyhhTRyM0TsUQ0MuRi3AqaBayp85T/c5AVMKwUv6mnXTQAAAABJRU5ErkJggg==', + qr:"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23fff' d='M3 3h8v8H3V3zm2 2v4h4V5H5zm8-2h8v8h-8V3zm2 2v4h4V5h-4zM3 13h8v8H3v-8zm2 2v4h4v-4H5zm8-2h3v3h-3v-3zm4 0h4v4h-2v-2h-2v-2zm-4 4h2v2h2v2h-4v-4zm5 1h3v3h-3v-3z'/%3E%3C/svg%3E", stitch:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYAgMAAACdGdVrAAAACVBMVEUAAAD///9XV1cawLSAAAAAKUlEQVQI12MIBQMGBwYgYMRBqQawRgAp0QDWEOwUHu349EGNxqsd6kAArN4RJ/MiMK0AAAAASUVORK5CYII=', downloadSvgBtn:'', video:'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIHZlcnNpb249IjEuMSI+PHBhdGggZD0iTTUxMiA2NEMyNjUuNiA2NCA2NCAyNjUuNiA2NCA1MTJzMjAxLjYgNDQ4IDQ0OCA0NDggNDQ4LTIwMS42IDQ0OC00NDhTNzU4LjQgNjQgNTEyIDY0ek02OTEuMiA1NDRsLTI1NiAxNTYuOEM0MjguOCA3MDQgNDIyLjQgNzA0IDQxNiA3MDRjLTYuNCAwLTkuNiAwLTE2LTMuMkMzOTAuNCA2OTQuNCAzODQgNjg0LjggMzg0IDY3MkwzODQgMzUyYzAtMTIuOCA2LjQtMjIuNCAxNi0yOC44IDkuNi02LjQgMjIuNC02LjQgMzIgMGwyNTYgMTY2LjRjOS42IDYuNCAxNiAxNiAxNiAyOC44QzcwNCA1MjggNzAwLjggNTQwLjggNjkxLjIgNTQ0eiIgZmlsbD0id2hpdGUiLz48L3N2Zz4=', @@ -24910,6 +24913,9 @@ ImgOps | https://imgops.com/#b#`; case 'magnifier': new MagnifierC(this.img,this.data); break; + case 'qr': + scanQrCode(this.img, this.data); + break; case 'download': downloadImg(this.data.src || this.data.imgSrc, (this.data.img.title || this.data.img.alt), prefs.saveName); break; @@ -24933,6 +24939,317 @@ ImgOps | https://imgops.com/#b#`; }, }; + + // Picviewer QR integration start + const qrI18n = /^zh(?:-|$)/i.test(navigator.language || '') ? { + scanTitle: '识别二维码', + scanningTitle: '正在识别二维码', + scanning: '正在读取原图并进行本地分析……', + resultTitle: '二维码识别结果', + cached: '(缓存)', + copied: '内容已复制到剪贴板。', + copy: '复制', + copiedAgain: '已重新复制。', + open: '打开链接', + close: '关闭', + failedTitle: '二维码识别失败', + noCode: '没有识别到二维码。请尝试使用更清晰的原图。', + imageFailed: '图片读取或解码失败。', + componentFailed: 'jsQR 识别组件未能加载。', + source: '图片来源:', + } : { + scanTitle: 'Scan QR code', + scanningTitle: 'Scanning QR code', + scanning: 'Reading the original image and analyzing it locally…', + resultTitle: 'QR code result', + cached: ' (cached)', + copied: 'The content was copied to the clipboard.', + copy: 'Copy', + copiedAgain: 'Copied again.', + open: 'Open link', + close: 'Close', + failedTitle: 'QR scan failed', + noCode: 'No QR code was detected. Try a clearer original image.', + imageFailed: 'The image could not be read or decoded.', + componentFailed: 'The jsQR decoder could not be loaded.', + source: 'Image source: ', + }; + const qrScanCache = new Map(); + const qrCacheTtl = 10 * 60 * 1000; + const qrMaxSide = 4096; + const qrMaxPixels = 16 * 1024 * 1024; + let qrDecoderPromise = null; + let qrDialogCleanup = null; + + async function scanQrCode(img, data) { + const src = (img && img.src) || (data && (data.src || data.imgSrc)); + if (!src) { + showQrMessage(qrI18n.failedTitle, qrI18n.imageFailed); + return; + } + + const cached = qrScanCache.get(src); + if (cached && Date.now() - cached.time < qrCacheTtl) { + showQrResult(cached.text, src, true); + return; + } + + showQrMessage(qrI18n.scanningTitle, qrI18n.scanning, true); + let input; + try { + input = await qrLoadImage(src, img); + const text = await qrDetect(input.canvas); + if (!text) throw new Error(qrI18n.noCode); + qrScanCache.set(src, { text: text, time: Date.now() }); + showQrResult(text, src, false); + } catch (error) { + console.warn('[Picviewer CE+ QR]', error); + showQrMessage(qrI18n.failedTitle, error && error.message ? error.message : qrI18n.imageFailed); + } finally { + if (input && input.cleanup) input.cleanup(); + } + } + + async function qrLoadImage(src, fallbackImg) { + let blob; + try { + blob = await qrUrlToBlob(src); + } catch (error) { + if (fallbackImg && fallbackImg.naturalWidth) { + const canvas = qrDrawToCanvas(fallbackImg, fallbackImg.naturalWidth, fallbackImg.naturalHeight); + return { canvas: canvas, cleanup: function() { canvas.remove(); } }; + } + throw error; + } + + if (typeof createImageBitmap === 'function') { + try { + const bitmap = await createImageBitmap(blob); + const canvas = qrDrawToCanvas(bitmap, bitmap.width, bitmap.height); + bitmap.close(); + return { canvas: canvas, cleanup: function() { canvas.remove(); } }; + } catch (error) { + console.debug('[Picviewer CE+ QR] createImageBitmap failed', error); + } + } + + const objectUrl = URL.createObjectURL(blob); + try { + const image = await new Promise(function(resolve, reject) { + const target = new Image(); + target.onload = function() { resolve(target); }; + target.onerror = function() { reject(new Error(qrI18n.imageFailed)); }; + target.src = objectUrl; + }); + const canvas = qrDrawToCanvas(image, image.naturalWidth || image.width, image.naturalHeight || image.height); + return { canvas: canvas, cleanup: function() { canvas.remove(); } }; + } finally { + URL.revokeObjectURL(objectUrl); + } + } + + function qrUrlToBlob(src) { + if (/^(?:blob:|data:)/i.test(src)) { + return fetch(src).then(function(response) { return response.blob(); }); + } + return new Promise(function(resolve, reject) { + urlToBlob(src, function(blob) { + if (blob && blob.size) resolve(blob); + else reject(new Error(qrI18n.imageFailed)); + }); + }); + } + + function qrDrawToCanvas(source, sourceWidth, sourceHeight) { + if (!sourceWidth || !sourceHeight) throw new Error(qrI18n.imageFailed); + let scale = Math.min(1, qrMaxSide / Math.max(sourceWidth, sourceHeight)); + scale = Math.min(scale, Math.sqrt(qrMaxPixels / (sourceWidth * sourceHeight))); + if (Math.max(sourceWidth, sourceHeight) < 900) { + scale = Math.min(4, 900 / Math.max(sourceWidth, sourceHeight)); + } + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(sourceWidth * scale)); + canvas.height = Math.max(1, Math.round(sourceHeight * scale)); + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) throw new Error(qrI18n.imageFailed); + context.imageSmoothingEnabled = scale < 1; + context.drawImage(source, 0, 0, canvas.width, canvas.height); + return canvas; + } + + async function qrDetect(canvas) { + const nativeText = await qrDetectNative(canvas); + if (nativeText) return nativeText; + const decoder = await qrLoadDecoder(); + const fullText = await qrDecodeCanvas(canvas, decoder); + if (fullText) return fullText; + if (canvas.width > 1800 || canvas.height > 1800) { + return qrDecodeTiles(canvas, decoder); + } + return ''; + } + + async function qrDetectNative(canvas) { + if (typeof BarcodeDetector !== 'function') return ''; + try { + if (typeof BarcodeDetector.getSupportedFormats === 'function') { + const formats = await BarcodeDetector.getSupportedFormats(); + if (!formats.includes('qr_code')) return ''; + } + const detector = new BarcodeDetector({ formats: ['qr_code'] }); + const results = await detector.detect(canvas); + return results && results[0] && results[0].rawValue ? results[0].rawValue : ''; + } catch (error) { + console.debug('[Picviewer CE+ QR] BarcodeDetector unavailable', error); + return ''; + } + } + + function qrLoadDecoder() { + if (qrDecoderPromise) return qrDecoderPromise; + qrDecoderPromise = Promise.resolve().then(function() { + const source = GM_getResourceText('jsQR'); + if (!source) throw new Error(qrI18n.componentFailed); + const scope = Object.create(null); + const factory = new Function('window', 'self', 'globalThis', source + '\n;return window.jsQR || self.jsQR || globalThis.jsQR;'); + const decoder = factory(scope, scope, scope); + if (typeof decoder !== 'function') throw new Error(qrI18n.componentFailed); + return decoder; + }).catch(function(error) { + qrDecoderPromise = null; + throw error; + }); + return qrDecoderPromise; + } + + async function qrDecodeCanvas(canvas, decoder) { + const context = canvas.getContext('2d', { willReadFrequently: true }); + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + await qrYield(); + let result = decoder(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'attemptBoth' }); + if (result && result.data) return result.data; + for (const threshold of [128, 176]) { + const data = new Uint8ClampedArray(imageData.data); + for (let index = 0; index < data.length; index += 4) { + const luminance = Math.round(data[index] * 0.299 + data[index + 1] * 0.587 + data[index + 2] * 0.114); + const value = luminance >= threshold ? 255 : 0; + data[index] = value; + data[index + 1] = value; + data[index + 2] = value; + } + await qrYield(); + result = decoder(data, imageData.width, imageData.height, { inversionAttempts: 'attemptBoth' }); + if (result && result.data) return result.data; + } + return ''; + } + + async function qrDecodeTiles(canvas, decoder) { + const overlap = 0.15; + const baseWidth = canvas.width / 2; + const baseHeight = canvas.height / 2; + for (let row = 0; row < 2; row++) { + for (let column = 0; column < 2; column++) { + const x = Math.max(0, Math.floor(column * baseWidth - baseWidth * overlap)); + const y = Math.max(0, Math.floor(row * baseHeight - baseHeight * overlap)); + const width = Math.min(canvas.width - x, Math.ceil(baseWidth * (1 + overlap * 2))); + const height = Math.min(canvas.height - y, Math.ceil(baseHeight * (1 + overlap * 2))); + const tile = document.createElement('canvas'); + tile.width = width; + tile.height = height; + tile.getContext('2d', { willReadFrequently: true }).drawImage(canvas, x, y, width, height, 0, 0, width, height); + const text = await qrDecodeCanvas(tile, decoder); + tile.remove(); + if (text) return text; + } + } + return ''; + } + + function qrYield() { + return new Promise(function(resolve) { setTimeout(resolve, 0); }); + } + + function showQrResult(text, src, cached) { + const value = String(text || '').trim(); + if (!value) { + showQrMessage(qrI18n.failedTitle, qrI18n.noCode); + return; + } + _GM_setClipboard(value); + const safeUrl = qrSafeHttpUrl(value); + const dialog = qrCreateDialog(); + const root = dialog.shadowRoot; + root.querySelector('.title').textContent = qrI18n.resultTitle + (cached ? qrI18n.cached : ''); + root.querySelector('.message').textContent = qrI18n.copied; + const output = root.querySelector('.output'); + output.hidden = false; + output.value = value; + const sourceLine = root.querySelector('.source'); + sourceLine.hidden = !src; + sourceLine.textContent = src ? qrI18n.source + src : ''; + const copyButton = root.querySelector('.copy'); + copyButton.hidden = false; + copyButton.textContent = qrI18n.copy; + copyButton.onclick = function() { + _GM_setClipboard(value); + root.querySelector('.message').textContent = qrI18n.copiedAgain; + }; + const openButton = root.querySelector('.open'); + openButton.hidden = !safeUrl; + openButton.textContent = qrI18n.open; + openButton.onclick = function() { + if (safeUrl) _GM_openInTab(safeUrl, { active: true, insert: true }); + }; + } + + function showQrMessage(title, message, loading) { + const dialog = qrCreateDialog(); + const root = dialog.shadowRoot; + root.querySelector('.title').textContent = title; + root.querySelector('.message').textContent = message; + root.querySelector('.spinner').hidden = !loading; + } + + function qrSafeHttpUrl(value) { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : ''; + } catch (error) { + return ''; + } + } + + function qrCreateDialog() { + if (qrDialogCleanup) qrDialogCleanup(); + const old = document.getElementById('pv-qr-result-host'); + if (old) old.remove(); + const host = document.createElement('div'); + host.id = 'pv-qr-result-host'; + const root = host.attachShadow({ mode: 'open' }); + root.innerHTML = '