Files
testing-tool/utils/base64Converter.ts
LingandRX 7167a43763 Develop (#25)
核心功能新增
工具套件扩展
JSON 工具(差异比较、格式化、转 YAML/TOML、压缩)
Base64 转换器(文本/文件/图像编解码)
Markdown ↔ HTML 双向转换
JWT 解析器
文本统计工具
二维码生成/解析(替换库以减小体积)
表单工具(已移除)
表单映射、智能填充、高亮定位功能已移除
架构与工程改进
技术栈升级
引入 @webext-core/messaging 重构消息通信
添加 @dnd-kit 支持功能列表拖拽排序
引入 i18next 实现中英文国际化
使用 MUI 主题系统替代硬编码样式,支持暗色模式
代码质量
统一组件导出为默认导出
提取公共组件(TextInputArea、SwitchButtonGroup、PageHeader、DecodeResultPaper)
使用 useStorageState 钩子统一管理存储状态
添加 PageErrorBoundary 页面级错误边界
实现组件懒加载优化首屏性能
关键重构
路由系统重构 — 支持独立标签页模式,合并路由与功能配置
状态管理优化 — 存储状态增加快照机制、验证器和防抖处理
剪贴板与打印工具 — 统一为 async/await 形式,修复重复触发问题
存储清理器 — 使用 TextEncoder 准确计算 UTF-8 字节数,支持 TB 单位
测试与 CI
测试覆盖 — 为新增工具、公共组件、边界场景补充单元测试
CI 精简 — 移除 Firefox 测试,仅保留 Chrome;修正类型检查命令
2026-05-20 19:55:49 +08:00

380 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Base64 转换器工具函数
*/
import { formatBytes } from './format';
/** 最大文件大小限制(10 MB) */
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
/** 支持的图像 MIME 类型 */
export const SUPPORTED_IMAGE_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/webp',
'image/gif',
'image/bmp',
'image/svg+xml',
'image/x-icon',
] as const;
/** 支持的图像文件扩展名 */
export const SUPPORTED_IMAGE_EXTENSIONS = [
'.png',
'.jpg',
'.jpeg',
'.webp',
'.gif',
'.bmp',
'.svg',
'.ico',
] as const;
/**
* 文本转 Base64 编码结果
*/
export interface TextToBase64Result {
/** Base64 编码结果 */
output: string;
/** 原始字节数 */
originalBytes: number;
/** 编码后字节数 */
outputBytes: number;
}
/**
* 文件转 Base64 编码结果
*/
export interface FileToBase64Result {
/** Base64 编码结果(含 data URI 前缀) */
output: string;
/** 纯 Base64 字符串(不含前缀) */
rawBase64: string;
/** 原始字节数 */
originalBytes: number;
/** 编码后字节数 */
outputBytes: number;
/** 文件名 */
fileName: string;
/** 文件 MIME 类型 */
mimeType: string;
}
/**
* 将文本字符串编码为 Base64
*
* @param text 输入文本
* @returns 编码结果
*/
export function textToBase64(text: string): TextToBase64Result {
const encoded = btoa(unescape(encodeURIComponent(text)));
const originalBytes = new TextEncoder().encode(text).length;
const outputBytes = new TextEncoder().encode(encoded).length;
return {
output: encoded,
originalBytes,
outputBytes,
};
}
/** 匹配 data URI 的 base64 前缀,如 "data:image/png;base64,"(允许中间含参数) */
const DATA_URI_BASE64_PREFIX = /^data:[^,]+;base64,/i;
/**
* 将 Base64 字符串解码为文本
*
* 支持 data:<mime>;base64,<payload> 形式:会自动剥离前缀后再解码。
* 若解码出的字节不是合法 UTF-8(典型如图片等二进制数据),抛出更易懂的错误。
*
* @param base64 Base64 编码字符串
* @returns 解码后的文本
* @throws {Error} 输入不是合法的 Base64 字符串
* @throws {Error} 输入解码后是二进制数据,无法作为文本展示
*/
export function base64ToText(base64: string): string {
const trimmed = base64.trim();
const cleaned = trimmed.replace(DATA_URI_BASE64_PREFIX, '');
if (!isValidBase64(cleaned)) {
throw new Error('Invalid Base64 string');
}
try {
return decodeURIComponent(escape(atob(cleaned)));
} catch {
throw new Error(
'Input appears to be binary data (e.g. an image). Please use the Image tab instead.',
);
}
}
/**
* 校验字符串是否为合法的 Base64 编码
*
* @param str 待校验字符串
* @returns 是否合法
*/
export function isValidBase64(str: string): boolean {
if (!str || str.length === 0) return false;
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Regex.test(str)) return false;
if (str.length % 4 !== 0) return false;
try {
atob(str);
return true;
} catch {
return false;
}
}
/**
* 校验文件大小是否在限制范围内
*
* @param fileSize 文件大小(字节)
* @returns 是否合法
*/
export function isFileSizeValid(fileSize: number): boolean {
return fileSize > 0 && fileSize <= MAX_FILE_SIZE;
}
/**
* 校验 MIME 类型是否为支持的图像类型
*
* @param mimeType MIME 类型
* @returns 是否为支持的图像类型
*/
export function isSupportedImageType(mimeType: string): boolean {
return (SUPPORTED_IMAGE_TYPES as readonly string[]).includes(mimeType);
}
/**
* 校验文件扩展名是否为支持的图像格式
*
* @param fileName 文件名
* @returns 是否为支持的图像格式
*/
export function isSupportedImageExtension(fileName: string): boolean {
const lowerName = fileName.toLowerCase();
return (SUPPORTED_IMAGE_EXTENSIONS as readonly string[]).some((ext) => lowerName.endsWith(ext));
}
/**
* 将文件转换为 Base64 编码(使用 FileReader 异步读取)
*
* @param file 文件对象
* @returns Promise<FileToBase64Result> 编码结果
* @throws {Error} 如果文件为空或超出大小限制
*/
export function fileToBase64(file: File): Promise<FileToBase64Result> {
return new Promise((resolve, reject) => {
if (!file) {
reject(new Error('No file provided'));
return;
}
if (!isFileSizeValid(file.size)) {
reject(new Error(`File size exceeds the limit (${MAX_FILE_SIZE / 1024 / 1024} MB)`));
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUri = reader.result as string;
const commaIndex = dataUri.indexOf(',');
const rawBase64 = commaIndex >= 0 ? dataUri.substring(commaIndex + 1) : dataUri;
resolve({
output: dataUri,
rawBase64,
originalBytes: file.size,
outputBytes: rawBase64.length,
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
});
};
reader.onerror = () => {
reject(new Error('Failed to read file'));
};
reader.readAsDataURL(file);
});
}
/**
* 从 data URI 中提取 MIME 类型
*
* @param dataUri data URI 字符串
* @returns MIME 类型
*/
export function extractMimeTypeFromDataUri(dataUri: string): string {
const match = dataUri.match(/^data:([^;,]+)[^,]*;base64,/);
return match ? match[1] : 'application/octet-stream';
}
/**
* 格式化文件大小显示(兼容旧接口,内部委托给 formatBytes
*
* @param bytes 字节数
* @returns 格式化后的字符串
*/
export function formatFileSize(bytes: number): string {
return formatBytes(bytes);
}
/**
* Base64 解码为二进制后的产物
*/
export interface Base64ToBlobResult {
/** 解码后的 Blob */
blob: Blob;
/** 推断出的 MIME 类型 */
mimeType: string;
/** 推荐的扩展名,含点(如 `.png`),无法识别时为 `.bin` */
suggestedExtension: string;
/** 已去除 data URI 前缀的纯 Base64 字符串 */
rawBase64: string;
}
/**
* 已知文件类型魔数签名表。注意:ZIP 头同样会匹配 .docx/.xlsx/.pptx/.apk
*
* 签名匹配规则:
* - `bytes` 必须匹配文件起始
* - 可选的 `tail` 用于多段签名(如 WebP"RIFF" + 偏移 8 处的 "WEBP"
*/
const MAGIC_BYTE_SIGNATURES: ReadonlyArray<{
bytes: readonly number[];
tail?: { offset: number; bytes: readonly number[] };
mime: string;
ext: string;
}> = [
{ bytes: [0x89, 0x50, 0x4e, 0x47], mime: 'image/png', ext: '.png' },
{ bytes: [0xff, 0xd8, 0xff], mime: 'image/jpeg', ext: '.jpg' },
{ bytes: [0x47, 0x49, 0x46, 0x38], mime: 'image/gif', ext: '.gif' },
{ bytes: [0x42, 0x4d], mime: 'image/bmp', ext: '.bmp' },
{
bytes: [0x52, 0x49, 0x46, 0x46],
tail: { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
mime: 'image/webp',
ext: '.webp',
},
{ bytes: [0x25, 0x50, 0x44, 0x46], mime: 'application/pdf', ext: '.pdf' },
{ bytes: [0x50, 0x4b, 0x03, 0x04], mime: 'application/zip', ext: '.zip' },
];
/**
* 将 Base64 字符串解码为 Uint8Array
*
* @param b64 纯 Base64 字符串(不含 data URI 前缀)
* @returns 字节序列
*/
export function base64ToBytes(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* 根据字节序列前缀识别已知文件类型
*
* @param bytes 解码后的字节序列
* @returns 匹配到的 MIME + 扩展名;未匹配返回 null
*/
export function sniffMimeFromBytes(bytes: Uint8Array): { mime: string; ext: string } | null {
for (const sig of MAGIC_BYTE_SIGNATURES) {
if (bytes.length < sig.bytes.length) continue;
let matched = true;
for (let i = 0; i < sig.bytes.length; i += 1) {
if (bytes[i] !== sig.bytes[i]) {
matched = false;
break;
}
}
if (!matched) continue;
if (sig.tail) {
const { offset, bytes: tailBytes } = sig.tail;
if (bytes.length < offset + tailBytes.length) continue;
let tailMatched = true;
for (let i = 0; i < tailBytes.length; i += 1) {
if (bytes[offset + i] !== tailBytes[i]) {
tailMatched = false;
break;
}
}
if (!tailMatched) continue;
}
return { mime: sig.mime, ext: sig.ext };
}
return null;
}
/**
* 将 Base64 / data URI 字符串解码为 Blob,自动推断 MIME 与扩展名
*
* MIME 推断优先级:data URI 前缀 → 字节魔数 → `application/octet-stream`
*
* @param input Base64 字符串或 data URI
* @returns 解码结果
* @throws {Error} 输入不是合法 Base64
*/
export function base64ToBlob(input: string): Base64ToBlobResult {
const trimmed = input.trim();
const prefixMatch = trimmed.match(DATA_URI_BASE64_PREFIX);
const cleaned = prefixMatch ? trimmed.slice(prefixMatch[0].length) : trimmed;
if (!isValidBase64(cleaned)) {
throw new Error('Invalid Base64 string');
}
const bytes = base64ToBytes(cleaned);
let mimeType: string;
let suggestedExtension: string;
if (prefixMatch) {
mimeType = extractMimeTypeFromDataUri(trimmed);
const sniffed = sniffMimeFromBytes(bytes);
suggestedExtension = sniffed?.ext ?? mimeTypeToExtension(mimeType);
} else {
const sniffed = sniffMimeFromBytes(bytes);
mimeType = sniffed?.mime ?? 'application/octet-stream';
suggestedExtension = sniffed?.ext ?? '.bin';
}
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: mimeType });
return { blob, mimeType, suggestedExtension, rawBase64: cleaned };
}
/** 极小的 MIME -> 扩展名映射,仅用于带 data URI 前缀但魔数无法识别时 */
function mimeTypeToExtension(mime: string): string {
if (mime.startsWith('image/svg')) return '.svg';
if (mime === 'image/webp') return '.webp';
if (mime === 'image/bmp') return '.bmp';
if (mime === 'image/x-icon' || mime === 'image/vnd.microsoft.icon') return '.ico';
if (mime === 'text/plain') return '.txt';
if (mime === 'application/json') return '.json';
if (mime === 'text/html') return '.html';
if (mime === 'text/css') return '.css';
return '.bin';
}
/**
* 触发浏览器下载指定 Blob
*
* @param blob 要下载的 Blob
* @param filename 下载文件名
*/
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}