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

668 lines
19 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.
<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) return null
if (item.sourceStart == null || item.sourceEnd == null) {
const pageNumber = item.sourcePages?.[0]
return pageNumber == null
? null
: pageRanges.value.find((page) => page.page_number === pageNumber) ?? 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) return ''
if (item.sourceStart == null || item.sourceEnd == null) {
return item.sourcePages?.includes(page.page_number) ? item.originalContent : ''
}
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)
&& !item.sourcePages?.length
)) {
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)
&& !item.sourcePages?.length
)) {
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>