diff --git a/cli/src/main.rs b/cli/src/main.rs index 638c8f1..c20a17b 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -74,7 +74,7 @@ fn main() -> anyhow::Result<()> { match ext.as_str() { "png" => { - let bytes = qr.to_png_bytes(args.size); + let bytes = qr.to_png_bytes(args.size)?; std::fs::write(&path, bytes)?; println!( "已生成: {} (版本 {}, {}×{} 模块, {} 级纠错)", diff --git a/core/src/ecc/galois.rs b/core/src/ecc/galois.rs index 33fa720..c28d807 100644 --- a/core/src/ecc/galois.rs +++ b/core/src/ecc/galois.rs @@ -1,46 +1,40 @@ /// GF(2⁸) Galois 域运算 /// 本原多项式: x⁸ + x⁴ + x³ + x² + 1 = 0x11D /// 生成元 α = 0x02 -use std::sync::OnceLock; +use std::sync::LazyLock; -fn exp_table() -> &'static [u8; 512] { - static TABLE: OnceLock<[u8; 512]> = OnceLock::new(); - TABLE.get_or_init(|| { - let mut table = [0u8; 512]; - let mut x = 1u8; - for i in 0..255 { - table[i] = x; - table[i + 255] = x; // 双倍长度避免 % 255 - let next = (x as u16) << 1; - x = if next >= 0x100 { - (next ^ 0x1D) as u8 - } else { - next as u8 - }; - } - table[510] = table[255]; - table[511] = table[256]; - table - }) -} +static EXP_TABLE: LazyLock<[u8; 512]> = LazyLock::new(|| { + let mut table = [0u8; 512]; + let mut x = 1u8; + for i in 0..255 { + table[i] = x; + table[i + 255] = x; // 双倍长度避免 % 255 + let next = (x as u16) << 1; + x = if next >= 0x100 { + (next ^ 0x1D) as u8 + } else { + next as u8 + }; + } + table[510] = table[255]; + table[511] = table[256]; + table +}); -fn log_table() -> &'static [u8; 256] { - static TABLE: OnceLock<[u8; 256]> = OnceLock::new(); - TABLE.get_or_init(|| { - let mut table = [0u8; 256]; - let mut x = 1u8; - for i in 0..255 { - table[x as usize] = i; - let next = (x as u16) << 1; - x = if next >= 0x100 { - (next ^ 0x1D) as u8 - } else { - next as u8 - }; - } - table - }) -} +static LOG_TABLE: LazyLock<[u8; 256]> = LazyLock::new(|| { + let mut table = [0u8; 256]; + let mut x = 1u8; + for i in 0..255 { + table[x as usize] = i; + let next = (x as u16) << 1; + x = if next >= 0x100 { + (next ^ 0x1D) as u8 + } else { + next as u8 + }; + } + table +}); /// GF(2⁸) 加法 = 异或 #[inline] @@ -60,9 +54,9 @@ pub fn mul(a: u8, b: u8) -> u8 { if a == 0 || b == 0 { return 0; } - let log_a = log_table()[a as usize] as usize; - let log_b = log_table()[b as usize] as usize; - exp_table()[log_a + log_b] + let log_a = LOG_TABLE[a as usize] as usize; + let log_b = LOG_TABLE[b as usize] as usize; + EXP_TABLE[log_a + log_b] } /// GF(2⁸) 除法:a / b,b == 0 时返回 None @@ -74,10 +68,10 @@ pub fn div(a: u8, b: u8) -> Option { if b == 0 { return None; } - let log_a = log_table()[a as usize] as usize; - let log_b = log_table()[b as usize] as usize; + let log_a = LOG_TABLE[a as usize] as usize; + let log_b = LOG_TABLE[b as usize] as usize; let diff = (log_a + 255 - log_b) % 255; - Some(exp_table()[diff]) + Some(EXP_TABLE[diff]) } /// GF(2⁸) 幂运算:base^exp @@ -89,8 +83,8 @@ pub fn pow(base: u8, exp: usize) -> u8 { if base == 0 { return 0; } - let log_b = log_table()[base as usize] as usize; - exp_table()[(log_b * exp) % 255] + let log_b = LOG_TABLE[base as usize] as usize; + EXP_TABLE[(log_b * exp) % 255] } #[cfg(test)] diff --git a/core/src/encoder/mode.rs b/core/src/encoder/mode.rs index 92ed5dd..6e7c82a 100644 --- a/core/src/encoder/mode.rs +++ b/core/src/encoder/mode.rs @@ -163,8 +163,8 @@ fn unicode_to_shift_jis(c: char) -> Option { let sjis = ((hi << 8) | lo) as u16; // 映射到 13-bit 码字(内层 if/else 已区分两个 Shift-JIS 区间) let val = { - let h = (sjis >> 8); - let l = (sjis & 0xFF); + let h = sjis >> 8; + let l = sjis & 0xFF; if (0x81..=0x9F).contains(&h) { (h - 0x81) * 0xBC + (l - 0x40) } else { @@ -277,6 +277,37 @@ mod tests { assert!(!is_kanji('A')); } + #[test] + fn test_kanji_encode_basic() { + // 常用汉字 "中文" 应能编码且长度正确 + let bits = encode_kanji("中文"); + // 2 个汉字,每个 13 bit = 26 bit + assert_eq!(bits.len(), 26); + } + + #[test] + fn test_unicode_to_shift_jis_known() { + // 基本汉字应返回 Some + assert!(unicode_to_shift_jis('中').is_some()); + assert!(unicode_to_shift_jis('文').is_some()); + assert!(unicode_to_shift_jis('你').is_some()); + } + + #[test] + fn test_unicode_to_shift_jis_ascii_returns_none() { + // ASCII 字符不是汉字 + assert!(unicode_to_shift_jis('A').is_none()); + assert!(unicode_to_shift_jis('1').is_none()); + } + + #[test] + fn test_kanji_mode_fallback() { + // 非 CJK 字符会被降级为 UTF-8 字节编码 + let bits = encode_kanji("A"); + // 1 个 ASCII 字符 = 8 bit + assert_eq!(bits.len(), 8); + } + fn bits_to_u16(bits: &[bool]) -> u16 { bits.iter().fold(0, |acc, &b| (acc << 1) | (b as u16)) } diff --git a/core/src/matrix/mask.rs b/core/src/matrix/mask.rs index eba6b70..9fce37c 100644 --- a/core/src/matrix/mask.rs +++ b/core/src/matrix/mask.rs @@ -185,19 +185,19 @@ fn score_rule4(matrix: &Matrix) -> u32 { pub fn best_mask(matrix: &Matrix) -> (u8, Matrix) { let mut best_idx = 0u8; let mut best_score = u32::MAX; - let mut best_matrix = matrix.clone(); + let mut best_matrix: Option = None; for i in 0..8u8 { let masked = apply_mask(matrix, i); let s = score(&masked); - if s < best_score { + if best_matrix.is_none() || s < best_score { best_score = s; best_idx = i; - best_matrix = masked; + best_matrix = Some(masked); } } - (best_idx, best_matrix) + (best_idx, best_matrix.unwrap()) } #[cfg(test)] diff --git a/core/src/matrix/patterns.rs b/core/src/matrix/patterns.rs index c50f4ef..0d77a06 100644 --- a/core/src/matrix/patterns.rs +++ b/core/src/matrix/patterns.rs @@ -24,6 +24,15 @@ pub fn place_finder_patterns(matrix: &mut Matrix) { matrix.reserve(x, y); } } + // 定位图案分隔符(1 模块宽的白色边框,在 finder 周围) + for i in 0..8u8 { + if fx + 7 < matrix.size { + matrix.reserve(fx + 7, fy + i); // 右侧分隔列 + } + if fy + 7 < matrix.size { + matrix.reserve(fx + i, fy + 7); // 底部隔行 + } + } } } @@ -59,11 +68,7 @@ fn place_single_alignment(matrix: &mut Matrix, cx: u8, cy: u8) { let y0 = cy - 2; for dy in 0..5u8 { for dx in 0..5u8 { - let is_dark = match (dx, dy) { - (0, _) | (4, _) | (_, 0) | (_, 4) => true, - (2, 2) => true, - _ => false, - }; + let is_dark = matches!((dx, dy), (0, _) | (4, _) | (_, 0) | (_, 4) | (2, 2)); let x = x0 + dx; let y = y0 + dy; matrix.set(x, y, is_dark); diff --git a/core/src/qr.rs b/core/src/qr.rs index bc9b257..dbd1867 100644 --- a/core/src/qr.rs +++ b/core/src/qr.rs @@ -143,7 +143,7 @@ impl QrCode { crate::render::ascii::render_ascii(self, invert) } - pub fn to_png_bytes(&self, module_size: u8) -> Vec { + pub fn to_png_bytes(&self, module_size: u8) -> Result, image::ImageError> { crate::render::png::render_png(self, module_size) } } diff --git a/core/src/render/png.rs b/core/src/render/png.rs index 3da61b3..c538075 100644 --- a/core/src/render/png.rs +++ b/core/src/render/png.rs @@ -1,7 +1,7 @@ use crate::qr::QrCode; use image::{ImageBuffer, Luma}; -pub fn render_png(qr: &QrCode, module_size: u8) -> Vec { +pub fn render_png(qr: &QrCode, module_size: u8) -> Result, image::ImageError> { let matrix_size = qr.size() as u32; let margin = qr.margin as u32; let total_size = matrix_size + 2 * margin; @@ -34,7 +34,6 @@ pub fn render_png(qr: &QrCode, module_size: u8) -> Vec { } let mut buf = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) - .expect("PNG 编码失败"); - buf + img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?; + Ok(buf) } diff --git a/core/tests/integration_test.rs b/core/tests/integration_test.rs index be946f1..df8e9b1 100644 --- a/core/tests/integration_test.rs +++ b/core/tests/integration_test.rs @@ -83,7 +83,7 @@ fn test_ascii_output() { #[test] fn test_png_output() { let qr = QrCode::encode("TEST", QrConfig::default()).unwrap(); - let png = qr.to_png_bytes(4); + let png = qr.to_png_bytes(4).unwrap(); assert!(!png.is_empty()); // PNG 文件应以 8 字节魔术签名开头 assert_eq!(&png[..8], &[137, 80, 78, 71, 13, 10, 26, 10]); diff --git a/gui/src-frontend/src/App.tsx b/gui/src-frontend/src/App.tsx index f8c293b..fca0c31 100644 --- a/gui/src-frontend/src/App.tsx +++ b/gui/src-frontend/src/App.tsx @@ -1,4 +1,5 @@ import { QrProvider, useQrState } from './store/qrContext'; +import ErrorBoundary from './components/ErrorBoundary'; import ModePanel from './components/ModePanel'; import QrPreview from './components/QrPreview'; import ExportPanel from './components/ExportPanel'; @@ -60,8 +61,10 @@ function BottomInput() { export default function App() { return ( - - - + + + + + ); } diff --git a/gui/src-frontend/src/components/ErrorBoundary.tsx b/gui/src-frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..2db1219 --- /dev/null +++ b/gui/src-frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,27 @@ +import React, { Component, type ReactNode } from 'react'; + +interface Props { children: ReactNode; } +interface State { hasError: boolean; error: Error | null; } + +export default class ErrorBoundary extends Component { + state: State = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error) { return { hasError: true, error }; } + + render() { + if (this.state.hasError) { + return ( +
+ +

应用发生错误

+

{this.state.error?.message}

+ +
+ ); + } + return this.props.children; + } +} diff --git a/gui/src-frontend/src/components/ExportPanel.tsx b/gui/src-frontend/src/components/ExportPanel.tsx index a779a8f..43a6d67 100644 --- a/gui/src-frontend/src/components/ExportPanel.tsx +++ b/gui/src-frontend/src/components/ExportPanel.tsx @@ -45,7 +45,7 @@ export default function ExportPanel() { }); await writeFile(filePath, new Uint8Array(bytes)); } catch (e) { - console.error('导出 PNG 失败:', e); + console.warn('导出 PNG 失败:', e); } setExporting(false); }; @@ -60,7 +60,7 @@ export default function ExportPanel() { if (!filePath) return; await writeFile(filePath, new TextEncoder().encode(state.preview.svg)); } catch (e) { - console.error('导出 SVG 失败:', e); + console.warn('导出 SVG 失败:', e); } }; diff --git a/gui/src-frontend/src/components/QrPreview.tsx b/gui/src-frontend/src/components/QrPreview.tsx index aca6604..7e48be2 100644 --- a/gui/src-frontend/src/components/QrPreview.tsx +++ b/gui/src-frontend/src/components/QrPreview.tsx @@ -15,6 +15,7 @@ export default function QrPreview() { return (
+ {/* SVG 由 Rust 端 qr-core 生成,仅含 和固定颜色,无用户文本嵌入 */}
{ + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + const encode = useCallback((text: string) => { if (!text.trim()) { dispatch({ type: 'SET_PREVIEW', payload: null }); @@ -33,18 +44,26 @@ export function useQrEncode() { }); dispatch({ type: 'SET_PREVIEW', payload: result }); - // 保存到历史 - dispatch({ - type: 'ADD_HISTORY', - payload: { - id: Date.now().toString(), - mode: modeRef.current, - content: text, - timestamp: Date.now(), - }, - }); + // 保存到历史(内存 + 持久化) + const entryId = Date.now().toString(); + const entry: HistoryEntry = { + id: entryId, + mode: modeRef.current, + content: text, + timestamp: Date.now(), + }; + dispatch({ type: 'ADD_HISTORY', payload: entry }); + + // 持久化到 tauri-plugin-store + try { + const store = await Store.load('history.json'); + const current = await store.get(HISTORY_KEY) || []; + const updated = [entry, ...current].slice(0, 50); + await store.set(HISTORY_KEY, updated); + await store.save(); + } catch { /* store 不可用时静默忽略 */ } } catch (e) { - console.error('QR 编码失败:', e); + // 编码失败已在 dispatch SET_PREVIEW(null) 中处理,无需额外日志 dispatch({ type: 'SET_PREVIEW', payload: null }); } }, 200); diff --git a/gui/src-frontend/src/store/qrContext.tsx b/gui/src-frontend/src/store/qrContext.tsx index 14ec3e0..d8a035f 100644 --- a/gui/src-frontend/src/store/qrContext.tsx +++ b/gui/src-frontend/src/store/qrContext.tsx @@ -1,5 +1,6 @@ -import React, { createContext, useContext, useReducer, type ReactNode } from 'react'; -import type { QrState, QrAction } from '../types'; +import React, { createContext, useContext, useReducer, useEffect, type ReactNode } from 'react'; +import { Store } from '@tauri-apps/plugin-store'; +import type { QrState, QrAction, HistoryEntry } from '../types'; const initialState: QrState = { mode: 'text', @@ -42,6 +43,18 @@ const QrContext = createContext<{ export function QrProvider({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(qrReducer, initialState); + + // 启动时从 store 加载持久化的历史记录 + useEffect(() => { + (async () => { + try { + const store = await Store.load('history.json'); + const history = await store.get('qr-history') || []; + dispatch({ type: 'SET_HISTORY', payload: history }); + } catch { /* store 不可用时忽略 */ } + })(); + }, []); + return {children}; } diff --git a/gui/src/lib.rs b/gui/src/lib.rs index 3e4daa1..4d11877 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs @@ -29,6 +29,10 @@ struct AppState { /// 编码 QR 码,返回 SVG + 元信息 #[tauri::command] fn encode_qr(text: String, level: String, margin: u8) -> Result { + if margin > 100 { + return Err("边距过大(最大 100)".into()); + } + let ec_level = match level.to_uppercase().as_str() { "L" => EcLevel::L, "M" => EcLevel::M, @@ -58,6 +62,10 @@ fn encode_qr(text: String, level: String, margin: u8) -> Result Result, String> { + if margin > 100 { + return Err("边距过大(最大 100)".into()); + } + let ec_level = match level.to_uppercase().as_str() { "L" => EcLevel::L, "M" => EcLevel::M, @@ -74,7 +82,7 @@ fn export_png(text: String, level: String, margin: u8, module_size: u8) -> Resul let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?; - Ok(qr.to_png_bytes(module_size)) + qr.to_png_bytes(module_size).map_err(|e| format!("PNG 导出失败: {}", e)) } /// 保存历史记录条目