tool.xpcool.com/src/views/ocr/OcrView.vue

209 lines
6.4 KiB
Vue
Raw 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.

<template>
<div class="page-wrap">
<h2 class="page-title">图片 OCR 识别</h2>
<p class="page-desc">
上传图片使用 WASM Tesseract 在浏览器本地识别中英文文字图片不会上传服务器
</p>
<t-card class="tool-section" title="上传图片">
<UploadDrop v-model:files="files" accept="image/*" tip="支持中文 / 英文识别" />
<img
v-if="previewUrl"
:src="previewUrl"
alt="待识别图片"
class="preview-img img-previewable"
title="点击预览大图"
@click="openViewer([previewUrl])"
/>
<div class="form-actions">
<t-button theme="primary" :loading="busy" :disabled="!file" @click="recognize">
开始识别
</t-button>
<t-button variant="outline" @click="clearAll">清空</t-button>
</div>
<t-progress v-if="busy" class="progress" :percentage="progress" :label="progressLabel" />
<t-alert
class="notice"
theme="info"
message="首次识别需从官方语言包源下载中英文语言数据(约 20MB浏览器会缓存。如需完全离线部署可将语言包放入 public/traineddata 并修改 LANG_PATH 常量(见 agents.md。"
/>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="resultText" class="tool-section" title="识别结果">
<t-textarea v-model="resultText" :autosize="{ minRows: 6, maxRows: 16 }" readonly />
<div class="form-actions">
<CopyButton :text="resultText" />
<t-button variant="outline" size="small" @click="downloadResult">
<template #icon><DownloadIcon /></template>
下载 TXT
</t-button>
</div>
</t-card>
<HistoryPanel
:items="history.items"
@select="(t: string) => (resultText = t)"
@remove="history.remove"
@clear="history.clear"
/>
<!-- 待识别图放大预览 -->
<AppImageViewer v-model:visible="viewerVisible" v-model:index="viewerIndex" :images="viewerImages" />
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { DownloadIcon } from 'tdesign-icons-vue-next'
import { createWorker } from 'tesseract.js'
// worker wasm 核心打包进产物依赖全部来自 npm不引入 CDN 脚本
import TesseractWorker from 'tesseract.js/dist/worker.min.js?url'
import TesseractCore from 'tesseract.js-core/tesseract-core.wasm.js?url'
import UploadDrop from '@/components/common/UploadDrop.vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
import { useImageViewer } from '@/composables/useImageViewer'
import AppImageViewer from '@/components/common/AppImageViewer.vue'
import { downloadText } from '@/composables/useDownload'
import { genFilename } from '@/utils'
/**
* 语言包路径语言数据属于运行时资源非代码库默认使用官方托管源
* 如需完全离线 *.traineddata.gz 放入 public/traineddata/ 后改为
* `${window.location.origin}/traineddata`
*/
const LANG_PATH = 'https://tessdata.projectnaptha.com/4.0.0'
/** Worker 实例类型避免依赖内部类型导出保证类型兼容 */
type WorkerInstance = Awaited<ReturnType<typeof createWorker>>
const files = ref<File[]>([])
const file = ref<File | null>(null)
const previewUrl = ref('')
/** 待识别图放大预览TDesign ImageViewer */
const { visible: viewerVisible, index: viewerIndex, images: viewerImages, open: openViewer } = useImageViewer()
const busy = ref(false)
const progress = ref(0)
const progressLabel = ref('')
const error = ref('')
const resultText = ref('')
const history = useHistory('ocr')
let worker: WorkerInstance | null = null
/** 懒创建并复用 worker避免每次识别重复初始化 */
async function getWorker(): Promise<WorkerInstance> {
if (worker) return worker
worker = await createWorker(['chi_sim', 'eng'], 1, {
workerPath: TesseractWorker,
corePath: TesseractCore,
langPath: LANG_PATH,
gzip: true,
logger: (m: { status: string; progress: number }) => {
// 仅在识别阶段更新进度条
if (m.status === 'recognizing text') {
progress.value = Math.round(m.progress * 100)
progressLabel.value = `识别中 ${progress.value}%`
}
},
})
return worker
}
/** 文件变化时(含拖拽删除/清空):更新待识别图片与预览 */
watch(files, (list) => {
error.value = ''
resultText.value = ''
previewUrl.value && URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
const raw = list[0]
if (raw instanceof File) {
file.value = raw
previewUrl.value = URL.createObjectURL(raw)
} else {
file.value = null
}
})
async function recognize() {
if (!file.value) {
error.value = '请先上传图片'
return
}
busy.value = true
progress.value = 0
progressLabel.value = '初始化引擎…'
error.value = ''
try {
const w = await getWorker()
const { data } = await w.recognize(file.value)
resultText.value = data.text.trim()
if (!resultText.value) {
error.value = '未识别到文字,请尝试更清晰的图片'
} else {
history.add(resultText.value.slice(0, 500))
}
} catch {
error.value = '识别失败:请检查网络(首次需下载语言包)或换一张图片重试'
} finally {
busy.value = false
progress.value = 0
}
}
function downloadResult() {
if (resultText.value) downloadText(resultText.value, genFilename('ocr', 'txt'))
}
function clearAll() {
files.value = []
file.value = null
error.value = ''
resultText.value = ''
previewUrl.value && URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
onBeforeUnmount(async () => {
// 页面卸载时释放 worker 资源
if (worker) {
try {
await worker.terminate()
} catch {
/* 忽略终止异常 */
}
worker = null
}
previewUrl.value && URL.revokeObjectURL(previewUrl.value)
})
</script>
<style scoped>
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.preview-img {
display: block;
max-width: 100%;
max-height: 300px;
margin-top: 12px;
border-radius: 8px;
border: 1px solid var(--td-border-level-1-color);
}
.progress {
margin-top: 12px;
}
.notice {
margin-top: 12px;
}
</style>