From 4755b3aa3c46d93286fbd156db5f26aa92ec56ee Mon Sep 17 00:00:00 2001
From: hellow0rld-lyh <69658025+hellow0rld-lyh@users.noreply.github.com>
Date: Tue, 14 Jul 2026 00:27:44 +0800
Subject: [PATCH 1/3] ci: add Picviewer QR patch workflow
---
.github/workflows/apply-picviewer-qr.yml | 57 ++++++++++++++++++++++++
1 file changed, 57 insertions(+)
create mode 100644 .github/workflows/apply-picviewer-qr.yml
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
From 1c68636dfdadc6d08e07748ed9caecb6b49e5229 Mon Sep 17 00:00:00 2001
From: hellow0rld-lyh <69658025+hellow0rld-lyh@users.noreply.github.com>
Date: Tue, 14 Jul 2026 00:29:45 +0800
Subject: [PATCH 2/3] build(picviewer): add QR integration patcher
---
Picviewer CE+/tools/apply-qr-patch.js | 396 ++++++++++++++++++++++++++
1 file changed, 396 insertions(+)
create mode 100644 Picviewer CE+/tools/apply-qr-patch.js
diff --git a/Picviewer CE+/tools/apply-qr-patch.js b/Picviewer CE+/tools/apply-qr-patch.js
new file mode 100644
index 00000000000..930fc96ece1
--- /dev/null
+++ b/Picviewer CE+/tools/apply-qr-patch.js
@@ -0,0 +1,396 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+const targetPath = path.resolve(__dirname, '..', 'Picviewer CE+.user.js');
+let source = fs.readFileSync(targetPath, 'utf8');
+const marker = '// Picviewer QR integration start';
+
+if (source.includes(marker)) {
+ console.log('Picviewer QR patch is already applied.');
+ process.exit(0);
+}
+
+function replaceOnce(searchValue, replacement, description) {
+ const matches = typeof searchValue === 'string'
+ ? source.split(searchValue).length - 1
+ : (source.match(new RegExp(searchValue.source, searchValue.flags.includes('g') ? searchValue.flags : `${searchValue.flags}g`)) || []).length;
+ if (matches !== 1) {
+ throw new Error(`${description}: expected exactly one match, found ${matches}`);
+ }
+ source = source.replace(searchValue, replacement);
+}
+
+source = source.replace(/^(\/\/ @version\s+).*$/m, '$12026.2.6.2');
+
+replaceOnce(
+ '// @require https://update.greasyfork.org/scripts/440698/1740314/pvcep_lang.js\n',
+ '// @require https://update.greasyfork.org/scripts/440698/1740314/pvcep_lang.js\n' +
+ '// @resource jsQR https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js\n',
+ 'add jsQR resource'
+);
+
+replaceOnce(
+ '// @grant GM_setClipboard\n',
+ '// @grant GM_setClipboard\n// @grant GM_getResourceText\n',
+ 'add GM_getResourceText grant'
+);
+
+replaceOnce(
+ "butonOrder:['actual','current','gallery','magnifier','download','stitch']",
+ "butonOrder:['actual','current','qr','gallery','magnifier','download','stitch']",
+ 'add QR button to default order'
+);
+
+replaceOnce(
+ /(\n\s*download:'data:image\/png;base64,[^'\n]+',\r?\n)/,
+ `$1 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",\n`,
+ 'add QR icon'
+);
+
+replaceOnce(
+ ' download:i18n("download")+` (${prefs.floatBar.keys.download.toUpperCase()})`,\n stitch:i18n("stitch"),',
+ ' download:i18n("download")+` (${prefs.floatBar.keys.download.toUpperCase()})`,\n qr:qrI18n.scanTitle,\n stitch:i18n("stitch"),',
+ 'add QR title'
+);
+
+replaceOnce(
+ ' #pv-float-bar-container .pv-float-bar-button-stitch {\\\n background-image:url("\'+ prefs.icons.stitch +\'")!important;\\\n }\\\n',
+ ' #pv-float-bar-container .pv-float-bar-button-qr {\\\n background-image:url("\'+ prefs.icons.qr +\'")!important;\\\n }\\\n #pv-float-bar-container .pv-float-bar-button-stitch {\\\n background-image:url("\'+ prefs.icons.stitch +\'")!important;\\\n }\\\n',
+ 'add QR button style'
+);
+
+replaceOnce(
+ " case 'download':\n downloadImg(this.data.src || this.data.imgSrc, (this.data.img.title || this.data.img.alt), prefs.saveName);",
+ " case 'qr':\n scanQrCode(this.img, this.data);\n break;\n case 'download':\n downloadImg(this.data.src || this.data.imgSrc, (this.data.img.title || this.data.img.alt), prefs.saveName);",
+ 'wire QR action into LoadingAnimC'
+);
+
+const qrImplementation = String.raw`
+ ${marker}
+ 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 = '
';
+ document.documentElement.appendChild(host);
+ const escapeHandler = function(event) {
+ if (event.key === 'Escape') close();
+ };
+ const close = function() {
+ document.removeEventListener('keydown', escapeHandler, true);
+ if (host.isConnected) host.remove();
+ if (qrDialogCleanup === close) qrDialogCleanup = null;
+ };
+ qrDialogCleanup = close;
+ root.querySelector('.dismiss').textContent = qrI18n.close;
+ root.querySelector('.dismiss').onclick = close;
+ root.querySelector('.close').title = qrI18n.close;
+ root.querySelector('.close').onclick = close;
+ root.querySelector('.backdrop').onclick = function(event) {
+ if (event.target.classList.contains('backdrop')) close();
+ };
+ document.addEventListener('keydown', escapeHandler, true);
+ return host;
+ }
+ // Picviewer QR integration end
+
+`;
+
+replaceOnce(
+ ' function copyData(url) {',
+ qrImplementation + ' function copyData(url) {',
+ 'insert QR implementation'
+);
+
+replaceOnce(
+ " title: 'actual,current,gallery,magnifier,download',",
+ " title: 'actual,current,qr,gallery,magnifier,download,stitch',",
+ 'update button order setting hint'
+);
+
+fs.writeFileSync(targetPath, source, 'utf8');
+console.log(`Applied Picviewer QR patch to ${targetPath}`);
From 55a29aac6cd145d6644c72f65267efa6d7eb9b48 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 13 Jul 2026 16:32:26 +0000
Subject: [PATCH 3/3] feat(picviewer): add QR code scanner
---
Picviewer CE+/Picviewer CE+.user.js | 327 +++++++++++++++++++++++++++-
1 file changed, 324 insertions(+), 3 deletions(-)
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 = '';
+ document.documentElement.appendChild(host);
+ const escapeHandler = function(event) {
+ if (event.key === 'Escape') close();
+ };
+ const close = function() {
+ document.removeEventListener('keydown', escapeHandler, true);
+ if (host.isConnected) host.remove();
+ if (qrDialogCleanup === close) qrDialogCleanup = null;
+ };
+ qrDialogCleanup = close;
+ root.querySelector('.dismiss').textContent = qrI18n.close;
+ root.querySelector('.dismiss').onclick = close;
+ root.querySelector('.close').title = qrI18n.close;
+ root.querySelector('.close').onclick = close;
+ root.querySelector('.backdrop').onclick = function(event) {
+ if (event.target.classList.contains('backdrop')) close();
+ };
+ document.addEventListener('keydown', escapeHandler, true);
+ return host;
+ }
+ // Picviewer QR integration end
+
function copyData(url) {
if (typeof ClipboardItem != 'undefined') {
urlToBlob(url, (blob, ext) => {
@@ -24987,6 +25304,7 @@ ImgOps | https://imgops.com/#b#`;
current:i18n("currentBtn").replace(/ ?\(C\)/,` (${prefs.floatBar.keys.current.toUpperCase()})`),
magnifier:i18n("magnifierBtn").replace(/ ?\(M\)/,` (${prefs.floatBar.keys.magnifier.toUpperCase()})`),
download:i18n("download")+` (${prefs.floatBar.keys.download.toUpperCase()})`,
+ qr:qrI18n.scanTitle,
stitch:i18n("stitch"),
};
var buttonName=prefs.floatBar.butonOrder[index];
@@ -25121,6 +25439,9 @@ ImgOps | https://imgops.com/#b#`;
#pv-float-bar-container .pv-float-bar-button-download {\
background-image:url("'+ prefs.icons.download +'")!important;\
}\
+ #pv-float-bar-container .pv-float-bar-button-qr {\
+ background-image:url("'+ prefs.icons.qr +'")!important;\
+ }\
#pv-float-bar-container .pv-float-bar-button-stitch {\
background-image:url("'+ prefs.icons.stitch +'")!important;\
}\
@@ -27698,7 +28019,7 @@ ImgOps | https://imgops.com/#b#`;
label: i18n("butonOrder"),
type: 'text',
className: 'order',
- title: 'actual,current,gallery,magnifier,download',
+ title: 'actual,current,qr,gallery,magnifier,download,stitch',
"default": prefs.floatBar.butonOrder.join(', '),
},
'floatBar.additionalFeature': {