adtec-core-package 3.1.5 → 3.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/adtec-core-package/adtec-core-package.css +1 -0
- package/adtec-core-package/adtec-core-package.js +41216 -0
- package/adtec-core-package/adtec-core-package.umd.cjs +87 -0
- package/adtec-core-package/favicon.ico +0 -0
- package/package.json +4 -2
- package/src/api/DocumentApi.ts +11 -1
- package/src/components/upload/DocxJsViewer.vue +286 -0
- package/src/components/upload/ExcelJsViewer.vue +270 -0
- package/src/components/upload/FileView.vue +104 -160
- package/src/components/upload/FileViewComponents.vue +2 -1
- package/src/components/upload/OfficePreview.vue +479 -0
- package/src/components/upload/OfficePreviewHeaderBar.vue +315 -0
- package/src/components/upload/OfficePreviewToolbar.vue +203 -0
- package/src/components/upload/PdfJsViewer.vue +920 -0
- package/src/utils/docxPreviewUtil.ts +108 -0
- package/src/utils/excelPreviewUtil.ts +101 -0
- package/src/utils/officePreviewUtil.ts +122 -0
- package/src/utils/pdfSearchUtil.ts +127 -0
- package/src/utils/toSameOriginFileUrl.ts +14 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export type WordBinaryFormat = 'docx' | 'doc' | 'unknown'
|
|
2
|
+
|
|
3
|
+
export interface DocxSearchMatch {
|
|
4
|
+
pageIndex: number
|
|
5
|
+
markId: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const OLE_DOC_MAGIC = [0xd0, 0xcf, 0x11, 0xe0]
|
|
9
|
+
const ZIP_MAGIC = [0x50, 0x4b]
|
|
10
|
+
|
|
11
|
+
export const detectWordBinaryFormat = (buffer: ArrayBuffer): WordBinaryFormat => {
|
|
12
|
+
const bytes = new Uint8Array(buffer, 0, 4)
|
|
13
|
+
if (bytes.length < 4) return 'unknown'
|
|
14
|
+
if (ZIP_MAGIC.every((value, index) => bytes[index] === value)) return 'docx'
|
|
15
|
+
if (OLE_DOC_MAGIC.every((value, index) => bytes[index] === value)) return 'doc'
|
|
16
|
+
return 'unknown'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const resolveRawWordExt = (type?: string, name?: string, fileUrl?: string) => {
|
|
20
|
+
const normalizedType = (type || '').toLowerCase().replace(/^\./, '')
|
|
21
|
+
if (normalizedType === 'doc' || normalizedType === 'docx') return normalizedType
|
|
22
|
+
const fromName = (name || '').match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase()
|
|
23
|
+
if (fromName === 'doc' || fromName === 'docx') return fromName
|
|
24
|
+
const urlStr = (fileUrl || '').toLowerCase()
|
|
25
|
+
if (urlStr.includes('.docx')) return 'docx'
|
|
26
|
+
if (urlStr.match(/\.doc(\?|#|$)/)) return 'doc'
|
|
27
|
+
return ''
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const collectTextNodes = (root: ParentNode) => {
|
|
31
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
|
32
|
+
const nodes: Text[] = []
|
|
33
|
+
let current = walker.nextNode()
|
|
34
|
+
while (current) {
|
|
35
|
+
if (current.textContent?.trim()) nodes.push(current as Text)
|
|
36
|
+
current = walker.nextNode()
|
|
37
|
+
}
|
|
38
|
+
return nodes
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const unwrapMarks = (root: ParentNode) => {
|
|
42
|
+
root.querySelectorAll('mark.docx-search-mark').forEach((mark) => {
|
|
43
|
+
const parent = mark.parentNode
|
|
44
|
+
if (!parent) return
|
|
45
|
+
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark)
|
|
46
|
+
parent.removeChild(mark)
|
|
47
|
+
})
|
|
48
|
+
root.normalize()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const clearDocxSearchMarks = (root: ParentNode) => {
|
|
52
|
+
unwrapMarks(root)
|
|
53
|
+
root.querySelectorAll('.docx-search-mark--active').forEach((el) => {
|
|
54
|
+
el.classList.remove('docx-search-mark--active')
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const searchDocxPages = (
|
|
59
|
+
pages: HTMLElement[],
|
|
60
|
+
query: string,
|
|
61
|
+
): DocxSearchMatch[] => {
|
|
62
|
+
const keyword = query.trim().toLowerCase()
|
|
63
|
+
if (!keyword) return []
|
|
64
|
+
const matches: DocxSearchMatch[] = []
|
|
65
|
+
|
|
66
|
+
pages.forEach((page, pageIndex) => {
|
|
67
|
+
unwrapMarks(page)
|
|
68
|
+
const textNodes = collectTextNodes(page)
|
|
69
|
+
for (const node of [...textNodes]) {
|
|
70
|
+
let text = node.textContent || ''
|
|
71
|
+
let lower = text.toLowerCase()
|
|
72
|
+
let fromIndex = 0
|
|
73
|
+
while (fromIndex < lower.length) {
|
|
74
|
+
const hit = lower.indexOf(keyword, fromIndex)
|
|
75
|
+
if (hit < 0) break
|
|
76
|
+
const parent = node.parentNode
|
|
77
|
+
if (!parent) break
|
|
78
|
+
const mark = document.createElement('mark')
|
|
79
|
+
mark.className = 'docx-search-mark'
|
|
80
|
+
const markId = `${pageIndex}-${matches.length}`
|
|
81
|
+
mark.dataset.matchId = markId
|
|
82
|
+
const before = text.slice(0, hit)
|
|
83
|
+
const middle = text.slice(hit, hit + keyword.length)
|
|
84
|
+
const after = text.slice(hit + keyword.length)
|
|
85
|
+
if (before) parent.insertBefore(document.createTextNode(before), node)
|
|
86
|
+
mark.textContent = middle
|
|
87
|
+
parent.insertBefore(mark, node)
|
|
88
|
+
if (after) {
|
|
89
|
+
node.textContent = after
|
|
90
|
+
text = after
|
|
91
|
+
lower = after.toLowerCase()
|
|
92
|
+
fromIndex = 0
|
|
93
|
+
} else {
|
|
94
|
+
parent.removeChild(node)
|
|
95
|
+
break
|
|
96
|
+
}
|
|
97
|
+
matches.push({ pageIndex, markId })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
return matches
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const activateDocxSearchMark = (root: ParentNode, markId: string) => {
|
|
105
|
+
root.querySelectorAll('.docx-search-mark').forEach((el) => {
|
|
106
|
+
el.classList.toggle('docx-search-mark--active', el instanceof HTMLElement && el.dataset.matchId === markId)
|
|
107
|
+
})
|
|
108
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import * as XLSX from 'xlsx-js-style'
|
|
2
|
+
|
|
3
|
+
export interface ExcelPreviewCell {
|
|
4
|
+
value: string
|
|
5
|
+
row: number
|
|
6
|
+
col: number
|
|
7
|
+
rowSpan?: number
|
|
8
|
+
colSpan?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ExcelPreviewSheet {
|
|
12
|
+
name: string
|
|
13
|
+
rows: ExcelPreviewCell[][]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ExcelSearchMatch {
|
|
17
|
+
sheetIndex: number
|
|
18
|
+
row: number
|
|
19
|
+
col: number
|
|
20
|
+
text: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const formatCellValue = (cell?: XLSX.CellObject) => {
|
|
24
|
+
if (!cell) return ''
|
|
25
|
+
if (cell.w != null && cell.w !== '') return String(cell.w)
|
|
26
|
+
if (cell.v == null) return ''
|
|
27
|
+
if (cell.t === 'd') {
|
|
28
|
+
const date = cell.v instanceof Date ? cell.v : new Date(cell.v as number)
|
|
29
|
+
if (!Number.isNaN(date.getTime())) return date.toLocaleString()
|
|
30
|
+
}
|
|
31
|
+
return String(cell.v)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const parseExcelWorkbook = async (src: Blob | ArrayBuffer): Promise<ExcelPreviewSheet[]> => {
|
|
35
|
+
const buffer = src instanceof Blob ? await src.arrayBuffer() : src
|
|
36
|
+
const workbook = XLSX.read(buffer, { type: 'array', cellDates: true })
|
|
37
|
+
return workbook.SheetNames.map((name) => ({
|
|
38
|
+
name,
|
|
39
|
+
rows: buildSheetRows(workbook.Sheets[name]),
|
|
40
|
+
}))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const buildSheetRows = (sheet?: XLSX.WorkSheet): ExcelPreviewCell[][] => {
|
|
44
|
+
if (!sheet || !sheet['!ref']) return []
|
|
45
|
+
const range = XLSX.utils.decode_range(sheet['!ref'])
|
|
46
|
+
const mergeStarts = new Map<string, { rowSpan: number; colSpan: number }>()
|
|
47
|
+
const covered = new Set<string>()
|
|
48
|
+
|
|
49
|
+
for (const merge of sheet['!merges'] || []) {
|
|
50
|
+
const rowSpan = merge.e.r - merge.s.r + 1
|
|
51
|
+
const colSpan = merge.e.c - merge.s.c + 1
|
|
52
|
+
mergeStarts.set(`${merge.s.r},${merge.s.c}`, { rowSpan, colSpan })
|
|
53
|
+
for (let r = merge.s.r; r <= merge.e.r; r++) {
|
|
54
|
+
for (let c = merge.s.c; c <= merge.e.c; c++) {
|
|
55
|
+
if (r !== merge.s.r || c !== merge.s.c) covered.add(`${r},${c}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const rows: ExcelPreviewCell[][] = []
|
|
61
|
+
for (let r = range.s.r; r <= range.e.r; r++) {
|
|
62
|
+
const row: ExcelPreviewCell[] = []
|
|
63
|
+
for (let c = range.s.c; c <= range.e.c; c++) {
|
|
64
|
+
if (covered.has(`${r},${c}`)) continue
|
|
65
|
+
const addr = XLSX.utils.encode_cell({ r, c })
|
|
66
|
+
const merge = mergeStarts.get(`${r},${c}`)
|
|
67
|
+
row.push({
|
|
68
|
+
value: formatCellValue(sheet[addr]),
|
|
69
|
+
row: r,
|
|
70
|
+
col: c,
|
|
71
|
+
rowSpan: merge?.rowSpan,
|
|
72
|
+
colSpan: merge?.colSpan,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
if (row.length > 0) rows.push(row)
|
|
76
|
+
}
|
|
77
|
+
return rows
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const searchExcelSheets = (
|
|
81
|
+
sheets: ExcelPreviewSheet[],
|
|
82
|
+
query: string,
|
|
83
|
+
): ExcelSearchMatch[] => {
|
|
84
|
+
const keyword = query.trim().toLowerCase()
|
|
85
|
+
if (!keyword) return []
|
|
86
|
+
const matches: ExcelSearchMatch[] = []
|
|
87
|
+
sheets.forEach((sheet, sheetIndex) => {
|
|
88
|
+
for (const row of sheet.rows) {
|
|
89
|
+
for (const cell of row) {
|
|
90
|
+
const text = cell.value
|
|
91
|
+
if (text.toLowerCase().includes(keyword)) {
|
|
92
|
+
matches.push({ sheetIndex, row: cell.row, col: cell.col, text })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
return matches
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const cellKey = (sheetIndex: number, row: number, col: number) =>
|
|
101
|
+
`${sheetIndex}:${row}:${col}`
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export type OfficePreviewType = 'pdf' | 'docx' | 'excel' | 'pptx' | 'txt' | ''
|
|
2
|
+
|
|
3
|
+
/** 当前支持在线预览的格式 */
|
|
4
|
+
export const SUPPORTED_PREVIEW_TYPES: ReadonlySet<OfficePreviewType> = new Set([
|
|
5
|
+
'pdf',
|
|
6
|
+
'docx',
|
|
7
|
+
'excel',
|
|
8
|
+
'txt',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
/** 使用内置 OfficePreviewHeaderBar 的格式(与 PDF 同款工具栏) */
|
|
12
|
+
export const HEADER_PREVIEW_TYPES: ReadonlySet<OfficePreviewType> = new Set([
|
|
13
|
+
'pdf',
|
|
14
|
+
'docx',
|
|
15
|
+
'excel',
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
export const isSupportedPreviewType = (type: OfficePreviewType) =>
|
|
19
|
+
SUPPORTED_PREVIEW_TYPES.has(type)
|
|
20
|
+
|
|
21
|
+
export const resolveOfficeFileExt = (type?: string, name?: string, fileUrl?: string) => {
|
|
22
|
+
const normalizedType = (type || '').toLowerCase().replace(/^\./, '')
|
|
23
|
+
if (normalizedType) {
|
|
24
|
+
return normalizedType === 'doc' ? 'docx' : normalizedType
|
|
25
|
+
}
|
|
26
|
+
const fromName = (name || '').match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase()
|
|
27
|
+
if (fromName) {
|
|
28
|
+
return fromName === 'doc' ? 'docx' : fromName
|
|
29
|
+
}
|
|
30
|
+
const urlStr = (fileUrl || '').toLowerCase()
|
|
31
|
+
if (urlStr.includes('.docx') || urlStr.includes('.doc')) return 'docx'
|
|
32
|
+
if (urlStr.includes('.pdf')) return 'pdf'
|
|
33
|
+
if (urlStr.includes('.xlsx') || urlStr.includes('.xls')) return 'xlsx'
|
|
34
|
+
if (urlStr.includes('.pptx') || urlStr.includes('.ppt')) return 'pptx'
|
|
35
|
+
if (urlStr.includes('.txt')) return 'txt'
|
|
36
|
+
return ''
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const toOfficePreviewType = (
|
|
40
|
+
type?: string,
|
|
41
|
+
name?: string,
|
|
42
|
+
fileUrl?: string,
|
|
43
|
+
): OfficePreviewType => {
|
|
44
|
+
const ext = resolveOfficeFileExt(type, name, fileUrl)
|
|
45
|
+
if (ext === 'pdf') return 'pdf'
|
|
46
|
+
if (ext === 'docx') return 'docx'
|
|
47
|
+
if (ext === 'xlsx' || ext === 'xls' || ext === 'excel') return 'excel'
|
|
48
|
+
if (ext === 'pptx' || ext === 'ppt') return 'pptx'
|
|
49
|
+
if (ext === 'txt') return 'txt'
|
|
50
|
+
return ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const MIN_OFFICE_ZOOM = 0.5
|
|
54
|
+
/** 非 PDF 预览(当前无在线预览)缩放上限 */
|
|
55
|
+
export const MAX_OFFICE_ZOOM = 1
|
|
56
|
+
/** pdf / excel 自研查看器支持高清放大 */
|
|
57
|
+
export const MAX_RICH_PREVIEW_ZOOM = 3
|
|
58
|
+
/** @deprecated 使用 MAX_RICH_PREVIEW_ZOOM */
|
|
59
|
+
export const MAX_PDF_ZOOM = MAX_RICH_PREVIEW_ZOOM
|
|
60
|
+
export const MAX_EXCEL_ZOOM = MAX_RICH_PREVIEW_ZOOM
|
|
61
|
+
export const OFFICE_ZOOM_STEP = 0.1
|
|
62
|
+
|
|
63
|
+
export const clampOfficeZoom = (value: number) =>
|
|
64
|
+
Math.min(MAX_OFFICE_ZOOM, Math.max(MIN_OFFICE_ZOOM, +value.toFixed(2)))
|
|
65
|
+
|
|
66
|
+
export const clampPdfZoom = (value: number) =>
|
|
67
|
+
Math.min(MAX_RICH_PREVIEW_ZOOM, Math.max(MIN_OFFICE_ZOOM, +value.toFixed(2)))
|
|
68
|
+
|
|
69
|
+
export const clampExcelZoom = clampPdfZoom
|
|
70
|
+
|
|
71
|
+
export const clampRichPreviewZoom = clampPdfZoom
|
|
72
|
+
|
|
73
|
+
export const clampPreviewZoom = (value: number, type: OfficePreviewType) =>
|
|
74
|
+
type === 'pdf' || type === 'excel' || type === 'docx'
|
|
75
|
+
? clampRichPreviewZoom(value)
|
|
76
|
+
: clampOfficeZoom(value)
|
|
77
|
+
|
|
78
|
+
/** PDF 点阵 DPI(pdfjs scale=1) */
|
|
79
|
+
export const PDF_POINT_DPI = 72
|
|
80
|
+
/** 屏幕 CSS 像素 DPI,用于「实际大小」换算 */
|
|
81
|
+
export const SCREEN_CSS_DPI = 96
|
|
82
|
+
/** A4 纸宽度(PDF 点) */
|
|
83
|
+
export const A4_WIDTH_PT = 595.28
|
|
84
|
+
|
|
85
|
+
/** A4 在屏幕上的目标宽度(px,约 794) */
|
|
86
|
+
export const getA4ScreenWidthPx = () =>
|
|
87
|
+
Math.round(A4_WIDTH_PT * (SCREEN_CSS_DPI / PDF_POINT_DPI))
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 默认以 A4 实际大小展示;预览区比 A4 窄时等比缩小以完整显示。
|
|
91
|
+
*/
|
|
92
|
+
export const computePdfDefaultFitScale = (
|
|
93
|
+
pageWidthPt: number,
|
|
94
|
+
containerWidth: number,
|
|
95
|
+
) => {
|
|
96
|
+
if (!pageWidthPt || !containerWidth) return SCREEN_CSS_DPI / PDF_POINT_DPI
|
|
97
|
+
const a4Scale = getA4ScreenWidthPx() / pageWidthPt
|
|
98
|
+
const containerScale = containerWidth / pageWidthPt
|
|
99
|
+
return Math.min(a4Scale, containerScale)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const isPdfPreviewFile = (type?: string, name?: string, fileUrl?: string) =>
|
|
103
|
+
toOfficePreviewType(type, name, fileUrl) === 'pdf'
|
|
104
|
+
|
|
105
|
+
export const isExcelPreviewFile = (type?: string, name?: string, fileUrl?: string) =>
|
|
106
|
+
toOfficePreviewType(type, name, fileUrl) === 'excel'
|
|
107
|
+
|
|
108
|
+
export const isDocxPreviewFile = (type?: string, name?: string, fileUrl?: string) =>
|
|
109
|
+
toOfficePreviewType(type, name, fileUrl) === 'docx'
|
|
110
|
+
|
|
111
|
+
export const isLegacyDocFile = (type?: string, name?: string, fileUrl?: string) => {
|
|
112
|
+
const raw = (type || '').toLowerCase().replace(/^\./, '')
|
|
113
|
+
if (raw === 'doc') return true
|
|
114
|
+
const fromName = (name || '').match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase()
|
|
115
|
+
if (fromName === 'doc') return true
|
|
116
|
+
const urlStr = (fileUrl || '').toLowerCase()
|
|
117
|
+
return !!urlStr.match(/\.doc(\?|#|$)/) && !urlStr.includes('.docx')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 弹层等场景:使用 OfficePreview 内置标题栏(PDF / Word / Excel) */
|
|
121
|
+
export const isHeaderPreviewFile = (type?: string, name?: string, fileUrl?: string) =>
|
|
122
|
+
HEADER_PREVIEW_TYPES.has(toOfficePreviewType(type, name, fileUrl))
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'
|
|
2
|
+
|
|
3
|
+
export interface PdfSearchMatch {
|
|
4
|
+
page: number
|
|
5
|
+
globalIndex: number
|
|
6
|
+
rects: Array<{ left: number; top: number; width: number; height: number }>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface TextSpan {
|
|
10
|
+
start: number
|
|
11
|
+
end: number
|
|
12
|
+
left: number
|
|
13
|
+
top: number
|
|
14
|
+
width: number
|
|
15
|
+
height: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type PdfViewportLike = {
|
|
19
|
+
height: number
|
|
20
|
+
width: number
|
|
21
|
+
scale: number
|
|
22
|
+
transform: number[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type PdfPageLike = {
|
|
26
|
+
getViewport: (opts: { scale: number }) => PdfViewportLike
|
|
27
|
+
getTextContent: () => Promise<{ items: Array<Record<string, unknown>> }>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type PdfDocumentLike = {
|
|
31
|
+
numPages: number
|
|
32
|
+
getPage: (pageNum: number) => Promise<PdfPageLike>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const normalizeQuery = (query: string) => query.trim().toLowerCase()
|
|
36
|
+
|
|
37
|
+
const getItemRect = (item: Record<string, unknown>, viewport: PdfViewportLike) => {
|
|
38
|
+
const transform = pdfjsLib.Util.transform(
|
|
39
|
+
viewport.transform,
|
|
40
|
+
item.transform as number[],
|
|
41
|
+
)
|
|
42
|
+
const fontHeight = Math.hypot(transform[2], transform[3]) || Number(item.height) || 12
|
|
43
|
+
const width = Math.abs(Number(item.width) || 0) * viewport.scale
|
|
44
|
+
return {
|
|
45
|
+
left: transform[4],
|
|
46
|
+
top: transform[5] - fontHeight,
|
|
47
|
+
width: Math.max(width, 4),
|
|
48
|
+
height: Math.max(fontHeight, 10),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const getPartialRect = (span: TextSpan, start: number, end: number) => {
|
|
53
|
+
const overlapStart = Math.max(start, span.start)
|
|
54
|
+
const overlapEnd = Math.min(end, span.end)
|
|
55
|
+
if (overlapEnd <= overlapStart) return null
|
|
56
|
+
|
|
57
|
+
const spanLen = Math.max(span.end - span.start, 1)
|
|
58
|
+
const offsetRatio = (overlapStart - span.start) / spanLen
|
|
59
|
+
const lengthRatio = (overlapEnd - overlapStart) / spanLen
|
|
60
|
+
const width = Math.max(span.width * lengthRatio, 4)
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
left: span.left + span.width * offsetRatio,
|
|
64
|
+
top: span.top,
|
|
65
|
+
width,
|
|
66
|
+
height: span.height,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const getRangeRects = (spans: TextSpan[], start: number, end: number) => {
|
|
71
|
+
const rects: Array<{ left: number; top: number; width: number; height: number }> = []
|
|
72
|
+
for (const span of spans) {
|
|
73
|
+
const rect = getPartialRect(span, start, end)
|
|
74
|
+
if (rect) rects.push(rect)
|
|
75
|
+
}
|
|
76
|
+
return rects
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const searchPdfDocument = async (
|
|
80
|
+
doc: PdfDocumentLike,
|
|
81
|
+
query: string,
|
|
82
|
+
scale: number,
|
|
83
|
+
): Promise<PdfSearchMatch[]> => {
|
|
84
|
+
const normalizedQuery = normalizeQuery(query)
|
|
85
|
+
if (!normalizedQuery) return []
|
|
86
|
+
|
|
87
|
+
const matches: PdfSearchMatch[] = []
|
|
88
|
+
let globalIndex = 0
|
|
89
|
+
|
|
90
|
+
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
|
|
91
|
+
const page = await doc.getPage(pageNum)
|
|
92
|
+
const viewport = page.getViewport({ scale })
|
|
93
|
+
const content = await page.getTextContent()
|
|
94
|
+
|
|
95
|
+
let fullText = ''
|
|
96
|
+
const spans: TextSpan[] = []
|
|
97
|
+
|
|
98
|
+
for (const item of content.items) {
|
|
99
|
+
if (!('str' in item) || typeof item.str !== 'string' || !item.str) continue
|
|
100
|
+
const rect = getItemRect(item, viewport)
|
|
101
|
+
const start = fullText.length
|
|
102
|
+
fullText += item.str
|
|
103
|
+
spans.push({
|
|
104
|
+
start,
|
|
105
|
+
end: fullText.length,
|
|
106
|
+
...rect,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const lower = fullText.toLowerCase()
|
|
111
|
+
let idx = lower.indexOf(normalizedQuery)
|
|
112
|
+
while (idx !== -1) {
|
|
113
|
+
const endIdx = idx + normalizedQuery.length
|
|
114
|
+
const rects = getRangeRects(spans, idx, endIdx)
|
|
115
|
+
if (rects.length > 0) {
|
|
116
|
+
matches.push({
|
|
117
|
+
page: pageNum,
|
|
118
|
+
globalIndex: globalIndex++,
|
|
119
|
+
rects,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
idx = lower.indexOf(normalizedQuery, idx + 1)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return matches
|
|
127
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器侧下载/拉取文件:将 fileurl 内网绝对地址转为当前页同源相对路径。
|
|
3
|
+
* 将内网绝对地址转为当前页同源相对路径,供浏览器侧拉取文件。
|
|
4
|
+
*/
|
|
5
|
+
export function toSameOriginFileUrl(url?: string | null): string {
|
|
6
|
+
if (!url) return ''
|
|
7
|
+
if (url.startsWith('/')) return url
|
|
8
|
+
try {
|
|
9
|
+
const parsed = new URL(url)
|
|
10
|
+
return parsed.pathname + parsed.search + parsed.hash
|
|
11
|
+
} catch {
|
|
12
|
+
return url
|
|
13
|
+
}
|
|
14
|
+
}
|