Files
YG_FT/frontend/src/views/data-process/create/PdfSourceViewer.vue

653 lines
18 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import {
GlobalWorkerOptions,
TextLayer,
getDocument,
type PDFDocumentLoadingTask,
type PDFDocumentProxy,
type RenderTask,
} from 'pdfjs-dist'
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import {
getDataProcessPdfPages,
getDataProcessSourceRawUrl,
type DataProcessPdfPageRange,
} from '@/api/modules/dataProcess'
import type { PreviewItem } from './types'
GlobalWorkerOptions.workerSrc = pdfWorkerUrl
const props = defineProps<{
taskId: string | number | null
sourceFileId: string | number | null
fileName: string
selectedItem: PreviewItem | null
}>()
const scrollRef = ref<HTMLElement | null>(null)
const pageRef = ref<HTMLElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const textLayerRef = ref<HTMLElement | null>(null)
const documentRef = shallowRef<PDFDocumentProxy | null>(null)
const pageRanges = ref<DataProcessPdfPageRange[]>([])
const currentPage = ref(1)
const pageCount = ref(0)
const zoom = ref(1)
const renderedScale = ref(1)
const loading = ref(false)
const rendering = ref(false)
const errorMessage = ref('')
const highlightState = ref<'idle' | 'highlighted' | 'unmatched' | 'manual'>('idle')
let loadingTask: PDFDocumentLoadingTask | null = null
let renderTask: RenderTask | null = null
let textLayer: TextLayer | null = null
let loadSequence = 0
let renderSequence = 0
let renderedPageNumber = 0
let resizeFrame = 0
const sourceUrl = computed(() => (
props.taskId != null && props.sourceFileId != null
? getDataProcessSourceRawUrl(props.taskId, props.sourceFileId)
: ''
))
const pageStyle = computed(() => ({
'--total-scale-factor': String(renderedScale.value),
}))
const locationText = computed(() => {
if (highlightState.value === 'highlighted') return `切片原文已在第 ${currentPage.value} 页高亮`
if (highlightState.value === 'unmatched') return `已定位第 ${currentPage.value} 页,未匹配到可高亮文字`
if (highlightState.value === 'manual') return '手动新增切片没有原文位置'
return pageCount.value ? `${currentPage.value} / ${pageCount.value}` : '正在读取 PDF'
})
function pageForItem(item: PreviewItem | null) {
if (!item || item.sourceStart == null || item.sourceEnd == null) return null
return pageRanges.value.find((page) => (
item.sourceStart! >= page.source_start && item.sourceStart! < page.source_end
)) ?? pageRanges.value.find((page) => (
item.sourceStart! < page.source_end && item.sourceEnd! > page.source_start
)) ?? null
}
function selectedTextForPage(page: DataProcessPdfPageRange) {
const item = props.selectedItem
if (!item || item.sourceStart == null || item.sourceEnd == null) return ''
const intersectionStart = Math.max(item.sourceStart, page.source_start)
const intersectionEnd = Math.min(item.sourceEnd, page.source_end)
if (intersectionEnd <= intersectionStart) return ''
const relativeStart = Math.max(0, intersectionStart - item.sourceStart)
const relativeEnd = Math.max(relativeStart, intersectionEnd - item.sourceStart)
return item.originalContent.slice(relativeStart, relativeEnd)
}
function normalizeLocatorText(value: string) {
return value.normalize('NFKC').replace(/[^\p{L}\p{N}]+/gu, '').toLocaleLowerCase()
}
function nearestOccurrence(haystack: string, needle: string, expectedIndex: number) {
let nearest = -1
let nearestDistance = Number.POSITIVE_INFINITY
let cursor = haystack.indexOf(needle)
while (cursor >= 0) {
const distance = Math.abs(cursor - expectedIndex)
if (distance < nearestDistance) {
nearest = cursor
nearestDistance = distance
}
cursor = haystack.indexOf(needle, cursor + 1)
}
return nearest
}
function findHighlight(
itemStrings: string[],
selectedText: string,
page: DataProcessPdfPageRange,
) {
const itemRanges: Array<{ start: number; end: number }> = []
let pageText = ''
for (const item of itemStrings) {
const start = pageText.length
pageText += normalizeLocatorText(item)
itemRanges.push({ start, end: pageText.length })
}
const target = normalizeLocatorText(selectedText)
if (!pageText || !target) return []
const itemStart = props.selectedItem?.sourceStart ?? page.source_start
const sourceLength = Math.max(1, page.source_end - page.source_start)
const expectedRatio = Math.min(1, Math.max(0, (itemStart - page.source_start) / sourceLength))
const expectedIndex = Math.round(pageText.length * expectedRatio)
const anchorLengths = [target.length, 120, 80, 48, 24, 12, 8]
.map((length) => Math.min(length, target.length))
.filter((length, index, values) => length >= 4 && values.indexOf(length) === index)
for (const anchorLength of anchorLengths) {
const anchor = target.slice(0, anchorLength)
const matchStart = nearestOccurrence(pageText, anchor, expectedIndex)
if (matchStart < 0) continue
const matchEnd = matchStart + anchor.length
return itemRanges.reduce<number[]>((matches, range, index) => {
if (range.end > matchStart && range.start < matchEnd) matches.push(index)
return matches
}, [])
}
return []
}
function applySelectedHighlight(page: DataProcessPdfPageRange | null) {
const item = props.selectedItem
for (const element of textLayer?.textDivs ?? []) {
element.classList.remove('is-slice-highlighted')
}
if (!item || item.sourceStart == null || item.sourceEnd == null) {
highlightState.value = item ? 'manual' : 'idle'
return
}
if (!page || page.page_number !== currentPage.value || !textLayer) {
highlightState.value = 'idle'
return
}
const matches = findHighlight(
textLayer.textContentItemsStr,
selectedTextForPage(page),
page,
)
for (const index of matches) {
textLayer.textDivs[index]?.classList.add('is-slice-highlighted')
}
highlightState.value = matches.length ? 'highlighted' : 'unmatched'
}
async function renderCurrentPage(force = false) {
const document = documentRef.value
const pageContainer = pageRef.value
const canvas = canvasRef.value
const layerContainer = textLayerRef.value
const scroller = scrollRef.value
if (!document || !pageContainer || !canvas || !layerContainer || !scroller) return
const selectedPage = pageForItem(props.selectedItem)
if (!force && renderedPageNumber === currentPage.value && textLayer) {
applySelectedHighlight(selectedPage)
return
}
const sequence = ++renderSequence
renderTask?.cancel()
textLayer?.cancel()
renderTask = null
textLayer = null
rendering.value = true
errorMessage.value = ''
try {
const page = await document.getPage(currentPage.value)
if (sequence !== renderSequence) return
const baseViewport = page.getViewport({ scale: 1 })
const availableWidth = Math.max(280, scroller.clientWidth - 36)
const scale = (availableWidth / baseViewport.width) * zoom.value
const viewport = page.getViewport({ scale })
const outputScale = Math.max(1, window.devicePixelRatio || 1)
const context = canvas.getContext('2d')
if (!context) throw new Error('浏览器无法创建 PDF 画布')
renderedScale.value = scale
pageContainer.style.width = `${viewport.width}px`
pageContainer.style.height = `${viewport.height}px`
canvas.width = Math.floor(viewport.width * outputScale)
canvas.height = Math.floor(viewport.height * outputScale)
canvas.style.width = `${viewport.width}px`
canvas.style.height = `${viewport.height}px`
layerContainer.replaceChildren()
const activeRenderTask = page.render({
canvas,
viewport,
transform: outputScale === 1 ? undefined : [outputScale, 0, 0, outputScale, 0, 0],
})
renderTask = activeRenderTask
// 立即挂接取消处理,避免 ResizeObserver 触发重绘时产生未处理的取消异常。
const canvasRenderPromise = activeRenderTask.promise.catch((error: unknown) => {
if (
sequence !== renderSequence
|| (error instanceof Error && error.name === 'RenderingCancelledException')
) return
throw error
})
// WebKit 兼容PDF.js 的 getTextContent() 依赖 ReadableStream 异步迭代,
// 部分 Safari/WKWebView 未实现该接口。TextLayer 可直接通过 getReader() 消费文本流。
const textContentSource = page.streamTextContent()
if (sequence !== renderSequence) return
const activeTextLayer = new TextLayer({
textContentSource,
container: layerContainer,
viewport,
})
textLayer = activeTextLayer
await Promise.all([canvasRenderPromise, activeTextLayer.render()])
if (sequence !== renderSequence) return
renderedPageNumber = currentPage.value
applySelectedHighlight(selectedPage)
await nextTick()
pageContainer.querySelector<HTMLElement>('.is-slice-highlighted')
?.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' })
} catch (error) {
if (sequence !== renderSequence) return
errorMessage.value = error instanceof Error ? error.message : 'PDF 页面渲染失败'
} finally {
if (sequence === renderSequence) rendering.value = false
}
}
async function locateSelectedItem() {
if (!documentRef.value) return
const item = props.selectedItem
if (!item || item.sourceStart == null || item.sourceEnd == null) {
applySelectedHighlight(null)
return
}
const page = pageForItem(item)
if (!page) {
highlightState.value = 'unmatched'
return
}
if (currentPage.value !== page.page_number) {
currentPage.value = page.page_number
await renderCurrentPage(true)
return
}
await renderCurrentPage(false)
}
async function loadPdf() {
const sequence = ++loadSequence
++renderSequence
renderedPageNumber = 0
renderTask?.cancel()
textLayer?.cancel()
await loadingTask?.destroy()
loadingTask = null
documentRef.value = null
pageRanges.value = []
pageCount.value = 0
currentPage.value = 1
errorMessage.value = ''
highlightState.value = 'idle'
if (!sourceUrl.value || props.taskId == null || props.sourceFileId == null) return
loading.value = true
try {
const task = getDocument({ url: sourceUrl.value })
loadingTask = task
const [mapping, document] = await Promise.all([
getDataProcessPdfPages(props.taskId, props.sourceFileId),
task.promise,
])
if (sequence !== loadSequence) {
await task.destroy()
return
}
documentRef.value = document
pageRanges.value = mapping.pages
pageCount.value = document.numPages
await nextTick()
await locateSelectedItem()
if (!props.selectedItem) await renderCurrentPage(true)
} catch (error) {
if (sequence !== loadSequence) return
errorMessage.value = error instanceof Error ? error.message : 'PDF 原文件加载失败'
} finally {
if (sequence === loadSequence) loading.value = false
}
}
async function changePage(delta: number) {
const nextPage = Math.min(pageCount.value, Math.max(1, currentPage.value + delta))
if (nextPage === currentPage.value) return
currentPage.value = nextPage
await renderCurrentPage(true)
}
async function changeZoom(delta: number) {
const nextZoom = Math.min(2, Math.max(0.6, Number((zoom.value + delta).toFixed(1))))
if (nextZoom === zoom.value) return
zoom.value = nextZoom
await renderCurrentPage(true)
}
function scheduleResizeRender() {
window.cancelAnimationFrame(resizeFrame)
resizeFrame = window.requestAnimationFrame(() => {
if (documentRef.value) void renderCurrentPage(true)
})
}
const resizeObserver = new ResizeObserver(scheduleResizeRender)
watch(scrollRef, (element, previous) => {
if (previous) resizeObserver.unobserve(previous)
if (element) resizeObserver.observe(element)
})
watch(sourceUrl, () => void loadPdf(), { immediate: true })
watch(() => props.selectedItem?.id, () => void locateSelectedItem())
onBeforeUnmount(() => {
++loadSequence
++renderSequence
window.cancelAnimationFrame(resizeFrame)
resizeObserver.disconnect()
renderTask?.cancel()
textLayer?.cancel()
void loadingTask?.destroy()
})
</script>
<template>
<div class="pdf-source-viewer" aria-label="PDF 原文件预览">
<div v-if="sourceUrl" class="pdf-toolbar">
<div class="toolbar-group">
<button type="button" aria-label="上一页" :disabled="currentPage <= 1 || loading" @click="changePage(-1)">
<i class="fa fa-chevron-left" aria-hidden="true" />
</button>
<span class="page-indicator">{{ currentPage }} / {{ pageCount || '—' }}</span>
<button type="button" aria-label="下一页" :disabled="currentPage >= pageCount || loading" @click="changePage(1)">
<i class="fa fa-chevron-right" aria-hidden="true" />
</button>
</div>
<span class="location-state" :class="`is-${highlightState}`" role="status">
<i v-if="highlightState === 'highlighted'" class="fa fa-map-marker" aria-hidden="true" />
{{ locationText }}
</span>
<div class="toolbar-group">
<button type="button" aria-label="缩小" :disabled="zoom <= 0.6 || loading" @click="changeZoom(-0.1)"></button>
<span class="zoom-indicator">{{ Math.round(zoom * 100) }}%</span>
<button type="button" aria-label="放大" :disabled="zoom >= 2 || loading" @click="changeZoom(0.1)">+</button>
</div>
</div>
<div v-if="sourceUrl" ref="scrollRef" class="pdf-scroll" tabindex="0" :aria-label="`PDF 预览:${fileName}`">
<div
ref="pageRef"
class="pdf-page"
:style="pageStyle"
:data-page-number="currentPage"
>
<canvas ref="canvasRef" class="pdf-canvas" />
<div ref="textLayerRef" class="pdf-text-layer" />
</div>
<div v-if="loading || rendering" class="pdf-loading" role="status">
<i class="fa fa-spinner fa-spin" aria-hidden="true" />
{{ loading ? '正在加载 PDF…' : '正在渲染页面…' }}
</div>
<div v-if="errorMessage" class="pdf-error" role="alert">
<i class="fa fa-exclamation-circle" aria-hidden="true" />
<strong>PDF 预览失败</strong>
<span>{{ errorMessage }}</span>
<a :href="sourceUrl" target="_blank" rel="noopener noreferrer">在新窗口打开原件</a>
</div>
</div>
<div v-else class="pdf-unavailable" role="status">
<i class="fa fa-file-pdf-o" aria-hidden="true" />
<strong>PDF 原文件暂不可预览</strong>
<span>请重新上传该文件后再试</span>
</div>
</div>
</template>
<style scoped lang="scss">
.pdf-source-viewer {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
background: #525659;
}
.pdf-toolbar {
display: grid;
min-height: 44px;
flex: 0 0 auto;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 0 12px;
color: #f2f4f7;
background: #323639;
border-bottom: 1px solid #1f2427;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 5px;
button {
display: inline-flex;
width: 30px;
height: 30px;
align-items: center;
justify-content: center;
color: #f2f4f7;
font: inherit;
font-size: 16px;
background: transparent;
border: 0;
border-radius: 4px;
cursor: pointer;
&:hover:not(:disabled) {
background: rgb(255 255 255 / 12%);
}
&:disabled {
color: #7d8387;
cursor: not-allowed;
}
}
}
.page-indicator,
.zoom-indicator {
min-width: 54px;
color: #e4e7ec;
font-size: 12px;
text-align: center;
}
.location-state {
min-width: 0;
overflow: hidden;
color: #d0d5dd;
font-size: 12px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
&.is-highlighted {
color: #ffd666;
}
&.is-unmatched {
color: #fdb022;
}
}
.pdf-scroll {
position: relative;
display: flex;
min-height: 538px;
flex: 1;
align-items: flex-start;
justify-content: center;
overflow: auto;
padding: 18px;
outline: none;
}
.pdf-page {
position: relative;
flex: 0 0 auto;
overflow: hidden;
background: #fff;
box-shadow: 0 2px 12px rgb(0 0 0 / 34%);
}
.pdf-canvas {
position: absolute;
inset: 0;
display: block;
}
.pdf-text-layer {
--min-font-size: 1;
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
--min-font-size-inv: calc(1 / var(--min-font-size));
position: absolute;
z-index: 1;
inset: 0;
overflow: clip;
color-scheme: only light;
line-height: 1;
letter-spacing: normal;
word-spacing: normal;
text-align: initial;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
}
.pdf-text-layer :deep(span),
.pdf-text-layer :deep(br) {
position: absolute;
color: transparent;
white-space: pre;
cursor: text;
user-select: text;
transform-origin: 0 0;
}
.pdf-text-layer > :deep(:not(.markedContent)),
.pdf-text-layer :deep(.markedContent span:not(.markedContent)) {
--font-height: 0;
--scale-x: 1;
--rotate: 0deg;
z-index: 1;
font-size: calc(var(--text-scale-factor) * var(--font-height));
transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
}
.pdf-text-layer :deep(.markedContent) {
display: contents;
}
.pdf-text-layer :deep(.is-slice-highlighted) {
margin: -2px;
padding: 2px;
background: rgb(255 202 40 / 48%);
border-radius: 3px;
box-shadow: 0 0 0 1px rgb(245 158 11 / 38%);
}
.pdf-text-layer :deep(::selection) {
color: transparent;
background: rgb(37 99 235 / 30%);
}
.pdf-loading,
.pdf-error {
position: absolute;
z-index: 3;
top: 50%;
left: 50%;
display: flex;
align-items: center;
justify-content: center;
transform: translate(-50%, -50%);
}
.pdf-loading {
gap: 8px;
padding: 10px 14px;
color: #f2f4f7;
font-size: 13px;
background: rgb(31 36 39 / 86%);
border-radius: 6px;
}
.pdf-error {
width: min(360px, calc(100% - 32px));
flex-direction: column;
gap: 8px;
padding: 20px;
color: #667085;
text-align: center;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgb(0 0 0 / 22%);
i {
color: #d92d20;
font-size: 28px;
}
strong {
color: #344054;
}
span,
a {
font-size: 12px;
}
}
.pdf-unavailable {
display: flex;
width: 100%;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #667085;
background: #f8f9fb;
i {
color: #d92d20;
font-size: 36px;
}
strong {
color: #344054;
font-size: 14px;
}
span {
font-size: 12px;
}
}
@media (max-width: 900px) {
.pdf-toolbar {
gap: 5px;
padding: 0 6px;
}
.location-state {
font-size: 11px;
}
.pdf-scroll {
min-height: 420px;
}
}
</style>