二维码扫描优化,但是开票页面必须在微信浏览器中打开。

该项目暂停。
This commit is contained in:
cheney 2026-07-10 09:56:52 +08:00
parent 0652323b85
commit 7e2f4ec210
7 changed files with 400 additions and 80 deletions

9
_build_apk.sh Normal file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -e
cd /mnt/d/workbench/tauri-android-invoice
export ANDROID_HOME="$HOME/android-sdk"
export NDK_HOME="$(ls -d $HOME/android-sdk/ndk/* 2>/dev/null | head -1)"
export JAVA_HOME="${JAVA_HOME:-/home/cheney/.local/share/mise/installs/java/17.0.2}"
echo "ANDROID_HOME=$ANDROID_HOME"
echo "NDK_HOME=$NDK_HOME"
npm run tauri android android-studio-script --release --target aarch64 2>&1 | tail -n 15

244
qr-test.html Normal file
View File

@ -0,0 +1,244 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>QR 诊断测试</title>
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js"></script>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background: #f5f5f5; }
h2 { margin: 0 0 8px; }
.card { background: #fff; border-radius: 8px; padding: 16px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
input[type=file] { margin: 4px 0 12px; }
img, canvas { max-width: 100%; border: 1px solid #ccc; border-radius: 6px; margin: 6px 0; }
button { padding: 8px 14px; border: none; border-radius: 6px; background: #1976d2; color: #fff; font-size: 14px; cursor: pointer; margin-right: 8px; }
.result { margin-top: 10px; padding: 10px; border-radius: 6px; font-size: 13px; white-space: pre-wrap; word-break: break-all; }
.ok { background: #e8f5e9; color: #2e7d32; }
.fail { background: #ffebee; color: #c62828; }
.info { background: #e3f2fd; color: #1565c0; }
.row { display: flex; flex-wrap: wrap; gap: 12px; }
.col { flex: 1; min-width: 280px; }
</style>
</head>
<body>
<h2>jsQR 二维码诊断工具</h2>
<div class="card">
<input type="file" id="file" accept="image/*" />
<button onclick="testAll()">全面诊断</button>
<button onclick="testFull()">原始尺寸识别</button>
<button onclick="testHalf()">50%缩放识别</button>
<button onclick="testQuarter()">25%缩放识别</button>
<button onclick="testCenter()">中心裁剪识别</button>
</div>
<div class="row">
<div class="col">
<h3>原图</h3>
<img id="preview" style="display:none" />
</div>
<div class="col">
<h3>当前处理结果</h3>
<canvas id="resultCanvas" style="display:none"></canvas>
</div>
</div>
<div id="diag" style="display:none">
<h3>诊断日志</h3>
<div id="log"></div>
</div>
<script>
const fileInput = document.getElementById('file');
const preview = document.getElementById('preview');
const resultCanvas = document.getElementById('resultCanvas');
const logDiv = document.getElementById('log');
const diagDiv = document.getElementById('diag');
let cachedImageData = null;
/** 获取图片的 ImageData 缓存。@returns {Promise<ImageData>} */
async function getImageData() {
if (cachedImageData) return cachedImageData;
const file = fileInput.files[0];
if (!file) throw new Error('请先选择图片');
const url = URL.createObjectURL(file);
preview.src = url;
preview.style.display = 'block';
const img = await loadImage(url);
const c = document.createElement('canvas');
c.width = img.width; c.height = img.height;
const ctx = c.getContext('2d');
ctx.drawImage(img, 0, 0);
cachedImageData = ctx.getImageData(0, 0, img.width, img.height);
URL.revokeObjectURL(url);
return cachedImageData;
}
/** 加载图片。@param {string} url @returns {Promise<HTMLImageElement>} */
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('图片加载失败'));
img.src = url;
});
}
/**
* 尝试 JSQR 识别并返回结果。
* @param {ImageData} imageData 图片数据。
* @param {string} label 测试标签。
* @returns {{ok:boolean, data?:string, msg:string}}
*/
function tryDecode(imageData, label) {
const start = performance.now();
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'attemptBoth',
});
const ms = (performance.now() - start).toFixed(0);
if (code) {
return { ok: true, data: code.data, msg: `${label}: 成功 (${ms}ms) → ${code.data}` };
}
return { ok: false, msg: `${label}: 失败 (${ms}ms) — ${imageData.width}x${imageData.height}` };
}
/**
* 缩放 ImageData 到指定倍数。
* @param {ImageData} src 源数据。
* @param {number} scale 缩放倍率 (0.5=一半, 0.25=四分之一)。
* @returns {ImageData}
*/
function scaleImageData(src, scale) {
const w = Math.floor(src.width * scale);
const h = Math.floor(src.height * scale);
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d');
const temp = document.createElement('canvas');
temp.width = src.width; temp.height = src.height;
temp.getContext('2d').putImageData(src, 0, 0);
ctx.drawImage(temp, 0, 0, w, h);
return ctx.getImageData(0, 0, w, h);
}
/**
* 裁剪图片中心区域(可能包含二维码的核心部分)。
* @param {ImageData} src 源数据。
* @returns {ImageData}
*/
function cropCenter(src) {
const cw = Math.floor(src.width * 0.6);
const ch = Math.floor(src.height * 0.6);
const ox = Math.floor((src.width - cw) / 2);
const oy = Math.floor((src.height - ch) / 2);
const c = document.createElement('canvas');
c.width = cw; c.height = ch;
const ctx = c.getContext('2d');
const temp = document.createElement('canvas');
temp.width = src.width; temp.height = src.height;
temp.getContext('2d').putImageData(src, 0, 0);
ctx.drawImage(temp, ox, oy, cw, ch, 0, 0, cw, ch);
return ctx.getImageData(0, 0, cw, ch);
}
/** 显示处理后的图片到 canvas。@param {ImageData} data */
function showResult(data) {
resultCanvas.width = data.width;
resultCanvas.height = data.height;
resultCanvas.getContext('2d').putImageData(data, 0, 0);
resultCanvas.style.display = 'block';
}
/** 添加日志。@param {string} msg @param {string} cls */
function addLog(msg, cls) {
const div = document.createElement('div');
div.className = 'result ' + cls;
div.textContent = msg;
logDiv.appendChild(div);
}
/** 原始尺寸识别。@returns {Promise<void>} */
async function testFull() {
cachedImageData = null;
diagDiv.style.display = 'block';
logDiv.innerHTML = '';
try {
const data = await getImageData();
addLog(`图片原始尺寸: ${data.width}x${data.height}`, 'info');
showResult(data);
const r = tryDecode(data, '原始尺寸');
addLog(r.msg, r.ok ? 'ok' : 'fail');
} catch (e) { addLog('错误: ' + e.message, 'fail'); }
}
/** 50% 缩放识别。@returns {Promise<void>} */
async function testHalf() {
try {
const data = await getImageData();
const half = scaleImageData(data, 0.5);
showResult(half);
addLog(`缩放至 50%: ${half.width}x${half.height}`, 'info');
const r = tryDecode(half, '50%缩放');
addLog(r.msg, r.ok ? 'ok' : 'fail');
} catch (e) { addLog('错误: ' + e.message, 'fail'); }
}
/** 25% 缩放识别。@returns {Promise<void>} */
async function testQuarter() {
try {
const data = await getImageData();
const quarter = scaleImageData(data, 0.25);
showResult(quarter);
addLog(`缩放至 25%: ${quarter.width}x${quarter.height}`, 'info');
const r = tryDecode(quarter, '25%缩放');
addLog(r.msg, r.ok ? 'ok' : 'fail');
} catch (e) { addLog('错误: ' + e.message, 'fail'); }
}
/** 中心裁剪识别。@returns {Promise<void>} */
async function testCenter() {
try {
const data = await getImageData();
const crop = cropCenter(data);
showResult(crop);
addLog(`中心裁剪: ${crop.width}x${crop.height}`, 'info');
const r = tryDecode(crop, '中心裁剪');
addLog(r.msg, r.ok ? 'ok' : 'fail');
} catch (e) { addLog('错误: ' + e.message, 'fail'); }
}
/** 全面诊断:依次尝试多种策略。@returns {Promise<void>} */
async function testAll() {
diagDiv.style.display = 'block';
logDiv.innerHTML = '';
try {
const data = await getImageData();
addLog(`图片原始尺寸: ${data.width}x${data.height} (${(data.width * data.height / 1000000).toFixed(1)}MP)`, 'info');
const tests = [
{ fn: () => tryDecode(data, '原始尺寸'), show: () => showResult(data) },
{ fn: () => tryDecode(scaleImageData(data, 0.5), '50%缩放'), show: () => showResult(scaleImageData(data, 0.5)) },
{ fn: () => tryDecode(scaleImageData(data, 0.25), '25%缩放'), show: () => showResult(scaleImageData(data, 0.25)) },
{ fn: () => tryDecode(scaleImageData(data, 0.125), '12.5%缩放'), show: () => showResult(scaleImageData(data, 0.125)) },
{ fn: () => tryDecode(cropCenter(data), '中心60%裁剪'), show: () => showResult(cropCenter(data)) },
{ fn: () => tryDecode(scaleImageData(cropCenter(data), 0.5), '中心裁剪+50%缩放'), show: () => showResult(scaleImageData(cropCenter(data), 0.5)) },
];
for (const t of tests) {
t.show();
const r = t.fn();
addLog(r.msg, r.ok ? 'ok' : 'fail');
if (r.ok) {
addLog('🎉 识别成功!内容: ' + r.data, 'ok');
break;
}
}
} catch (e) {
addLog('错误: ' + e.message, 'fail');
}
}
</script>
</body>
</html>

View File

@ -1,7 +1,10 @@
// 二维码解码核心逻辑:与 UI、相机、Tauri 解耦,便于单元测试。
// 二维码解码核心逻辑:与 UI、相机、Tauri 解耦,便于单元测试。
// 相机扫码走原生插件,图片扫码走纯 JS 的 jsQR桌面端也可用。
import jsQR from "jsqr";
/** 多级缩放倍率,由大到小依次尝试,命中即停止。 */
const SCALE_TRIES = [1, 0.5, 0.25, 0.125];
/**
* 校验扫码内容是否为有效网址仅接受 http/https
* @param {unknown} content 扫码得到的原始内容
@ -14,8 +17,56 @@ export function extractUrl(content) {
}
/**
* 从像素数据中解码二维码
* @param {{data: Uint8ClampedArray, width: number, height: number}} imageData
* 纯像素运算将 ImageData 等比缩放至指定倍率不依赖 Canvas API可测试
* 对每个输出像素取对应输入区域的平均值适合缩小场景
* @param {{data:Uint8ClampedArray,width:number,height:number}} src 源像素数据
* @param {number} scale 缩放倍率0.5 = 一半0.25 = 四分之一
* @returns {{data:Uint8ClampedArray,width:number,height:number}} 缩放后的像素数据
*/
export function scaleImageData(src, scale) {
if (scale >= 1) return src;
const sw = src.width;
const sh = src.height;
const dw = Math.max(1, Math.floor(sw * scale));
const dh = Math.max(1, Math.floor(sh * scale));
const srcData = src.data;
const out = new Uint8ClampedArray(dw * dh * 4);
const xRatio = sw / dw;
const yRatio = sh / dh;
for (let dy = 0; dy < dh; dy += 1) {
const sy0 = Math.floor(dy * yRatio);
const sy1 = Math.min(sh, Math.floor((dy + 1) * yRatio));
for (let dx = 0; dx < dw; dx += 1) {
const sx0 = Math.floor(dx * xRatio);
const sx1 = Math.min(sw, Math.floor((dx + 1) * xRatio));
let r = 0, g = 0, b = 0, a = 0, count = 0;
for (let sy = sy0; sy < sy1; sy += 1) {
for (let sx = sx0; sx < sx1; sx += 1) {
const idx = (sy * sw + sx) * 4;
r += srcData[idx];
g += srcData[idx + 1];
b += srcData[idx + 2];
a += srcData[idx + 3];
count += 1;
}
}
const oi = (dy * dw + dx) * 4;
if (count > 0) {
out[oi] = Math.round(r / count);
out[oi + 1] = Math.round(g / count);
out[oi + 2] = Math.round(b / count);
out[oi + 3] = Math.round(a / count);
}
}
}
return { data: out, width: dw, height: dh };
}
/**
* 从像素数据中解码二维码多级缩放回退
* 对手机拍摄的高分辨率照片二维码可能只占很小区域jsQR 全图扫描容易失败
* 本函数先尝试原始尺寸失败后依次等比缩小再试大幅提升实际场景识别率
* @param {{data:Uint8ClampedArray,width:number,height:number}} imageData
* 画布像素数据RGBA
* @returns {string|null} 解码到的文本未识别到返回 null
*/
@ -23,11 +74,15 @@ export function decodeQrFromImageData(imageData) {
if (!imageData || !imageData.data || !imageData.width || !imageData.height) {
return null;
}
// attemptBoth 允许识别反色(浅底深码或深底浅码)二维码,提升清晰码的识别率。
const result = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: "attemptBoth",
});
return result ? result.data : null;
const opts = { inversionAttempts: "attemptBoth" };
let current = imageData;
for (const scale of SCALE_TRIES) {
const result = jsQR(current.data, current.width, current.height, opts);
if (result) return result.data;
if (scale === SCALE_TRIES[SCALE_TRIES.length - 1]) break;
current = scaleImageData(current, 0.5); // 每次缩一半
}
return null;
}
/**

View File

@ -1,4 +1,4 @@
<script setup>
<script setup>
import { ref, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import {
@ -11,8 +11,7 @@ import {
} from "@tauri-apps/plugin-barcode-scanner";
import { extractUrl, decodeQrFromFile } from "../qrscan.js";
// JS
// Android WebView
// jsQR
const router = useRouter();
const error = ref("");
const denied = ref(false);
@ -35,20 +34,6 @@ function toMessage(e) {
}
}
/**
* 切换扫码透明态透明时可看到 WebView 下层的相机预览
* 同时作用于 html/body #app 挂载点避免残留不透明背景挡住画面
* @param {boolean} on 是否进入透明态
* @returns {void}
*/
function setTransparent(on) {
const val = on ? "transparent" : "";
document.documentElement.style.background = val;
document.body.style.background = val;
const app = document.getElementById("app");
if (app) app.style.background = val;
}
/**
* 命中网址后跳转到 WebForm 非网址则提示
* @param {string} content 扫码/解码得到的原始文本
@ -77,7 +62,11 @@ async function ensurePermission() {
return state === "granted";
}
/** 启动相机扫码,成功后携带 URL 跳转到 WebForm 页。@returns {Promise<void>} */
/**
* 启动相机扫码原生全屏模式最可靠无需处理透明背景
* 成功后携带 URL 跳转到 WebForm
* @returns {Promise<void>}
*/
async function startScan() {
error.value = "";
if (scanning.value) return;
@ -88,18 +77,15 @@ async function startScan() {
return;
}
scanning.value = true;
setTransparent(true);
const res = await scan({
windowed: true,
windowed: false,
formats: [Format.QRCode],
cameraDirection: "back",
});
handleContent(res.content);
} catch (e) {
error.value = "扫码失败或已取消:" + toMessage(e);
} finally {
scanning.value = false;
setTransparent(false);
}
}
@ -111,7 +97,6 @@ async function stopScan() {
/* 忽略取消异常 */
}
scanning.value = false;
setTransparent(false);
}
/** 触发图片选择框。@returns {void} */
@ -121,13 +106,13 @@ function pickImage() {
}
/**
* 从用户选择的图片解码二维码
* 从用户选择的图片解码二维码jsQR 多级缩放回退
* @param {Event} evt input change 事件
* @returns {Promise<void>}
*/
async function onFileChange(evt) {
const file = evt.target.files && evt.target.files[0];
evt.target.value = ""; //
evt.target.value = "";
if (!file) return;
error.value = "";
decoding.value = true;
@ -145,22 +130,24 @@ async function onFileChange(evt) {
}
}
//
//
onUnmounted(() => {
if (scanning.value) stopScan();
setTransparent(false);
});
</script>
<template>
<div>
<!-- 非扫码态说明与入口 -->
<div v-if="!scanning" class="card">
<div class="card">
<p>点击下方按钮将小票二维码对准取景框即可自动识别也可从相册选择二维码图片</p>
<button @click="startScan">开始扫码</button>
<button class="plain" style="margin-left:8px" :disabled="decoding" @click="pickImage">
{{ decoding ? "识别中..." : "从图片扫描" }}
</button>
<div class="actions">
<button :disabled="scanning" @click="startScan">
{{ scanning ? "扫码中…" : "开始扫码" }}
</button>
<button class="plain" :disabled="decoding" @click="pickImage">
{{ decoding ? "识别中…" : "从图片扫描" }}
</button>
</div>
<input
ref="fileInput"
type="file"
@ -173,44 +160,9 @@ onUnmounted(() => {
去系统设置开启相机权限
</button>
</div>
<!-- 扫码态透明覆盖层仅显示取景框与取消按钮 -->
<div v-else class="scan-overlay">
<div class="scan-frame">
<span class="corner tl"></span><span class="corner tr"></span>
<span class="corner bl"></span><span class="corner br"></span>
</div>
<p class="scan-tip">将二维码放入框内自动识别</p>
<button class="plain" @click="stopScan">取消</button>
</div>
</div>
</template>
<style scoped>
/* 扫码覆盖层:铺满全屏,除取景框外区域半透明以聚焦二维码 */
.scan-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
}
.scan-frame {
position: relative;
width: 66vw;
height: 66vw;
max-width: 300px;
max-height: 300px;
box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.45);
border-radius: 8px;
}
.corner { position: absolute; width: 24px; height: 24px; border: 3px solid #21c17a; }
.corner.tl { top: -2px; left: -2px; border-right: none; border-bottom: none; }
.corner.tr { top: -2px; right: -2px; border-left: none; border-bottom: none; }
.corner.bl { bottom: -2px; left: -2px; border-right: none; border-top: none; }
.corner.br { bottom: -2px; right: -2px; border-left: none; border-top: none; }
.scan-tip { color: #fff; font-size: 14px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); }
.actions { display: flex; gap: 8px; }
</style>

BIN
tests/images/二维码1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB

BIN
tests/images/二维码2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

View File

@ -1,8 +1,9 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect } from "vitest";
import QRCode from "qrcode";
import {
extractUrl,
decodeQrFromImageData,
scaleImageData,
} from "../src/qrscan.js";
/**
@ -18,11 +19,10 @@ function renderQrImageData(text, scale = 8, quiet = 4) {
const modules = qr.modules.data;
const dim = (size + quiet * 2) * scale;
const data = new Uint8ClampedArray(dim * dim * 4);
// 先整体填白(含静区)。
data.fill(255);
for (let row = 0; row < size; row += 1) {
for (let col = 0; col < size; col += 1) {
if (!modules[row * size + col]) continue; // 0 = 浅色,跳过
if (!modules[row * size + col]) continue;
const x0 = (col + quiet) * scale;
const y0 = (row + quiet) * scale;
for (let dy = 0; dy < scale; dy += 1) {
@ -39,6 +39,45 @@ function renderQrImageData(text, scale = 8, quiet = 4) {
return { data, width: dim, height: dim };
}
/**
* 将小二维码嵌入大画布中央模拟手机拍摄的远景照片二维码占比小
* @param {string} text 二维码内容
* @param {number} canvasW 大画布宽度
* @param {number} canvasH 大画布高度
* @returns {ImageData} 大画布像素数据
*/
function embedSmallQr(text, canvasW = 600, canvasH = 400) {
const qr = QRCode.create(text, { errorCorrectionLevel: "M" });
const size = qr.modules.size;
const modules = qr.modules.data;
const scale = 3; // 每个模块 3px二维码约 80px
const quiet = 1;
const qrW = (size + quiet * 2) * scale;
const qrH = qrW;
const ox = Math.floor((canvasW - qrW) / 2);
const oy = Math.floor((canvasH - qrH) / 2);
const data = new Uint8ClampedArray(canvasW * canvasH * 4);
data.fill(255);
for (let row = 0; row < size; row += 1) {
for (let col = 0; col < size; col += 1) {
if (!modules[row * size + col]) continue;
const x0 = ox + (col + quiet) * scale;
const y0 = oy + (row + quiet) * scale;
for (let dy = 0; dy < scale; dy += 1) {
for (let dx = 0; dx < scale; dx += 1) {
const idx = ((y0 + dy) * canvasW + (x0 + dx)) * 4;
data[idx] = 0;
data[idx + 1] = 0;
data[idx + 2] = 0;
data[idx + 3] = 255;
}
}
}
}
return { data, width: canvasW, height: canvasH };
}
describe("qrscan", () => {
// 4-1 网址校验:接受 http/https拒绝其它内容。
it("4-1 extractUrl 仅接受 http/https 网址", () => {
@ -68,4 +107,25 @@ describe("qrscan", () => {
expect(decodeQrFromImageData(blank)).toBeNull();
expect(decodeQrFromImageData(null)).toBeNull();
});
// 4-4 多级缩放回退:大画布中小二维码,原始尺寸失败但缩小后成功。
it("4-4 多级缩放回退能识别大图中的小二维码", () => {
const url = "https://inv.example.com/small";
const imageData = embedSmallQr(url, 800, 600);
const result = decodeQrFromImageData(imageData);
expect(result).toBe(url);
});
// 4-5 scaleImageData 等比缩放正确。
it("4-5 scaleImageData 等比缩放", () => {
const src = renderQrImageData("test", 4);
const half = scaleImageData(src, 0.5);
expect(half.width).toBe(Math.floor(src.width * 0.5));
expect(half.height).toBe(Math.floor(src.height * 0.5));
expect(half.data).toBeInstanceOf(Uint8ClampedArray);
expect(half.data.length).toBe(half.width * half.height * 4);
expect(half.height).toBe(Math.floor(src.height * 0.5));
});
});