adtec-core-package 3.1.6 → 3.1.8
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/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/adtec-core-package-3.1.7.tgz +0 -0
- package/package/.editorconfig +6 -0
- package/package/adtec-core-package/adtec-core-package.css +1 -0
- package/package/adtec-core-package/adtec-core-package.js +41216 -0
- package/package/adtec-core-package/adtec-core-package.umd.cjs +87 -0
- package/package/adtec-core-package/favicon.ico +0 -0
- package/package/index.html +13 -0
- package/package/prebuilt/umo-editor/favicon.ico +0 -0
- package/package/prebuilt/umo-editor/umo-editor.css +1 -0
- package/package/public/favicon.ico +0 -0
- package/package/src/assets/base.css +86 -0
- package/package/src/assets/main.css +35 -0
- package/package/src/components/editor-main/src/extensions/bookmark.js +110 -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 +444 -0
- package/src/components/upload/FileView.vue +102 -159
- package/src/components/upload/OfficePreview.vue +481 -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 +307 -0
- package/src/utils/officePreviewUtil.ts +122 -0
- package/src/utils/pdfSearchUtil.ts +127 -0
- package/src/utils/toSameOriginFileUrl.ts +1 -1
|
@@ -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,307 @@
|
|
|
1
|
+
import * as XLSX from 'xlsx-js-style'
|
|
2
|
+
|
|
3
|
+
export interface ExcelPreviewCellStyle {
|
|
4
|
+
backgroundColor?: string
|
|
5
|
+
color?: string
|
|
6
|
+
fontWeight?: string
|
|
7
|
+
fontStyle?: string
|
|
8
|
+
textDecoration?: string
|
|
9
|
+
textAlign?: 'left' | 'center' | 'right' | 'justify'
|
|
10
|
+
verticalAlign?: 'top' | 'middle' | 'bottom'
|
|
11
|
+
borderTop?: string
|
|
12
|
+
borderRight?: string
|
|
13
|
+
borderBottom?: string
|
|
14
|
+
borderLeft?: string
|
|
15
|
+
fontSize?: string
|
|
16
|
+
whiteSpace?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ExcelPreviewCell {
|
|
20
|
+
value: string
|
|
21
|
+
row: number
|
|
22
|
+
col: number
|
|
23
|
+
rowSpan?: number
|
|
24
|
+
colSpan?: number
|
|
25
|
+
style?: ExcelPreviewCellStyle
|
|
26
|
+
width?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ExcelPreviewSheet {
|
|
30
|
+
name: string
|
|
31
|
+
rows: ExcelPreviewCell[][]
|
|
32
|
+
startCol: number
|
|
33
|
+
endCol: number
|
|
34
|
+
colWidths: Record<number, number>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ExcelSearchMatch {
|
|
38
|
+
sheetIndex: number
|
|
39
|
+
row: number
|
|
40
|
+
col: number
|
|
41
|
+
text: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type ColorStyle = { rgb?: string; theme?: number; tint?: number; indexed?: number }
|
|
45
|
+
|
|
46
|
+
/** Excel 默认主题色(Office 2013+) */
|
|
47
|
+
const EXCEL_THEME_COLORS: Record<number, string> = {
|
|
48
|
+
0: '#FFFFFF',
|
|
49
|
+
1: '#000000',
|
|
50
|
+
2: '#E7E6E6',
|
|
51
|
+
3: '#44546A',
|
|
52
|
+
4: '#4472C4',
|
|
53
|
+
5: '#ED7D31',
|
|
54
|
+
6: '#A5A5A5',
|
|
55
|
+
7: '#FFC000',
|
|
56
|
+
8: '#5B9BD5',
|
|
57
|
+
9: '#70AD47',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const BORDER_WIDTH: Record<string, string> = {
|
|
61
|
+
hair: '1px',
|
|
62
|
+
thin: '1px',
|
|
63
|
+
medium: '2px',
|
|
64
|
+
thick: '3px',
|
|
65
|
+
dashed: '1px',
|
|
66
|
+
dotted: '1px',
|
|
67
|
+
double: '3px',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULT_COL_WIDTH = 72
|
|
71
|
+
|
|
72
|
+
const hexToRgb = (hex: string) => {
|
|
73
|
+
const normalized = hex.replace('#', '')
|
|
74
|
+
const value =
|
|
75
|
+
normalized.length === 8 ? normalized.slice(2) : normalized.length === 6 ? normalized : ''
|
|
76
|
+
if (value.length !== 6) return null
|
|
77
|
+
return {
|
|
78
|
+
r: parseInt(value.slice(0, 2), 16),
|
|
79
|
+
g: parseInt(value.slice(2, 4), 16),
|
|
80
|
+
b: parseInt(value.slice(4, 6), 16),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const rgbToHex = (r: number, g: number, b: number) =>
|
|
85
|
+
`#${[r, g, b]
|
|
86
|
+
.map((v) => Math.min(255, Math.max(0, Math.round(v))).toString(16).padStart(2, '0'))
|
|
87
|
+
.join('')}`
|
|
88
|
+
|
|
89
|
+
const applyTint = (hex: string, tint: number) => {
|
|
90
|
+
const rgb = hexToRgb(hex)
|
|
91
|
+
if (!rgb) return hex
|
|
92
|
+
const mix = tint < 0 ? { r: 0, g: 0, b: 0 } : { r: 255, g: 255, b: 255 }
|
|
93
|
+
const ratio = Math.abs(tint)
|
|
94
|
+
return rgbToHex(
|
|
95
|
+
rgb.r + (mix.r - rgb.r) * ratio,
|
|
96
|
+
rgb.g + (mix.g - rgb.g) * ratio,
|
|
97
|
+
rgb.b + (mix.b - rgb.b) * ratio,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const resolveColor = (color?: ColorStyle): string | undefined => {
|
|
102
|
+
if (!color) return undefined
|
|
103
|
+
if (color.rgb) {
|
|
104
|
+
const raw = color.rgb.replace(/[^0-9a-f]/gi, '')
|
|
105
|
+
if (raw.length === 8) return `#${raw.slice(2)}`
|
|
106
|
+
if (raw.length === 6) return `#${raw}`
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
if (color.theme != null) {
|
|
110
|
+
const base = EXCEL_THEME_COLORS[color.theme] ?? '#FFFFFF'
|
|
111
|
+
return color.tint ? applyTint(base, color.tint) : base
|
|
112
|
+
}
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const resolveBorderSide = (side?: { style?: string; color?: ColorStyle }) => {
|
|
117
|
+
if (!side?.style || side.style === 'none') return undefined
|
|
118
|
+
const width = BORDER_WIDTH[side.style] ?? '1px'
|
|
119
|
+
const color = resolveColor(side.color) ?? '#d0d0d0'
|
|
120
|
+
const lineStyle = side.style === 'dashed' ? 'dashed' : side.style === 'dotted' ? 'dotted' : 'solid'
|
|
121
|
+
return `${width} ${lineStyle} ${color}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const extractCellStyle = (cell?: XLSX.CellObject): ExcelPreviewCellStyle | undefined => {
|
|
125
|
+
const rawStyle = cell?.s
|
|
126
|
+
if (!rawStyle || typeof rawStyle === 'number') return undefined
|
|
127
|
+
const style = rawStyle as Record<string, unknown>
|
|
128
|
+
const css: ExcelPreviewCellStyle = {}
|
|
129
|
+
|
|
130
|
+
const fill = style.fill as { patternType?: string; fgColor?: ColorStyle; bgColor?: ColorStyle } | undefined
|
|
131
|
+
if (fill && fill.patternType !== 'none') {
|
|
132
|
+
const bg = resolveColor(fill.fgColor) || resolveColor(fill.bgColor)
|
|
133
|
+
if (bg) css.backgroundColor = bg
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const font = style.font as {
|
|
137
|
+
bold?: boolean
|
|
138
|
+
italic?: boolean
|
|
139
|
+
underline?: boolean | string
|
|
140
|
+
sz?: number | string
|
|
141
|
+
color?: ColorStyle
|
|
142
|
+
} | undefined
|
|
143
|
+
if (font) {
|
|
144
|
+
if (font.bold) css.fontWeight = 'bold'
|
|
145
|
+
if (font.italic) css.fontStyle = 'italic'
|
|
146
|
+
if (font.underline) css.textDecoration = 'underline'
|
|
147
|
+
if (font.sz) css.fontSize = `${font.sz}pt`
|
|
148
|
+
const color = resolveColor(font.color)
|
|
149
|
+
if (color) css.color = color
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const alignment = style.alignment as {
|
|
153
|
+
horizontal?: string
|
|
154
|
+
vertical?: string
|
|
155
|
+
wrapText?: boolean
|
|
156
|
+
} | undefined
|
|
157
|
+
if (alignment) {
|
|
158
|
+
if (alignment.horizontal && alignment.horizontal !== 'general') {
|
|
159
|
+
css.textAlign = alignment.horizontal as ExcelPreviewCellStyle['textAlign']
|
|
160
|
+
}
|
|
161
|
+
if (alignment.vertical) {
|
|
162
|
+
css.verticalAlign =
|
|
163
|
+
alignment.vertical === 'center'
|
|
164
|
+
? 'middle'
|
|
165
|
+
: (alignment.vertical as ExcelPreviewCellStyle['verticalAlign'])
|
|
166
|
+
}
|
|
167
|
+
if (alignment.wrapText) css.whiteSpace = 'pre-wrap'
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const border = style.border as Record<string, { style?: string; color?: ColorStyle }> | undefined
|
|
171
|
+
if (border) {
|
|
172
|
+
const top = resolveBorderSide(border.top)
|
|
173
|
+
const right = resolveBorderSide(border.right)
|
|
174
|
+
const bottom = resolveBorderSide(border.bottom)
|
|
175
|
+
const left = resolveBorderSide(border.left)
|
|
176
|
+
if (top) css.borderTop = top
|
|
177
|
+
if (right) css.borderRight = right
|
|
178
|
+
if (bottom) css.borderBottom = bottom
|
|
179
|
+
if (left) css.borderLeft = left
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return Object.keys(css).length > 0 ? css : undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const cellStyleToCss = (style?: ExcelPreviewCellStyle): Record<string, string> => {
|
|
186
|
+
if (!style) return {}
|
|
187
|
+
const css: Record<string, string> = {}
|
|
188
|
+
if (style.backgroundColor) css.backgroundColor = style.backgroundColor
|
|
189
|
+
if (style.color) css.color = style.color
|
|
190
|
+
if (style.fontWeight) css.fontWeight = style.fontWeight
|
|
191
|
+
if (style.fontStyle) css.fontStyle = style.fontStyle
|
|
192
|
+
if (style.textDecoration) css.textDecoration = style.textDecoration
|
|
193
|
+
if (style.textAlign) css.textAlign = style.textAlign
|
|
194
|
+
if (style.verticalAlign) css.verticalAlign = style.verticalAlign
|
|
195
|
+
if (style.borderTop) css.borderTop = style.borderTop
|
|
196
|
+
if (style.borderRight) css.borderRight = style.borderRight
|
|
197
|
+
if (style.borderBottom) css.borderBottom = style.borderBottom
|
|
198
|
+
if (style.borderLeft) css.borderLeft = style.borderLeft
|
|
199
|
+
if (style.fontSize) css.fontSize = style.fontSize
|
|
200
|
+
if (style.whiteSpace) css.whiteSpace = style.whiteSpace
|
|
201
|
+
return css
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const toExcelColumnLabel = (colIndex: number) => XLSX.utils.encode_col(colIndex)
|
|
205
|
+
|
|
206
|
+
const formatCellValue = (cell?: XLSX.CellObject) => {
|
|
207
|
+
if (!cell) return ''
|
|
208
|
+
if (cell.w != null && cell.w !== '') return String(cell.w)
|
|
209
|
+
if (cell.v == null) return ''
|
|
210
|
+
if (cell.t === 'd') {
|
|
211
|
+
const date = cell.v instanceof Date ? cell.v : new Date(cell.v as number)
|
|
212
|
+
if (!Number.isNaN(date.getTime())) return date.toLocaleString()
|
|
213
|
+
}
|
|
214
|
+
return String(cell.v)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const parseColWidths = (sheet: XLSX.WorkSheet, startCol: number, endCol: number) => {
|
|
218
|
+
const cols = sheet['!cols'] || []
|
|
219
|
+
const widths: Record<number, number> = {}
|
|
220
|
+
for (let c = startCol; c <= endCol; c++) {
|
|
221
|
+
const col = cols[c]
|
|
222
|
+
if (col?.wpx) widths[c] = col.wpx
|
|
223
|
+
else if (col?.wch) widths[c] = Math.max(48, col.wch * 7)
|
|
224
|
+
else widths[c] = DEFAULT_COL_WIDTH
|
|
225
|
+
}
|
|
226
|
+
return widths
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export const parseExcelWorkbook = async (src: Blob | ArrayBuffer): Promise<ExcelPreviewSheet[]> => {
|
|
230
|
+
const buffer = src instanceof Blob ? await src.arrayBuffer() : src
|
|
231
|
+
const workbook = XLSX.read(buffer, { type: 'array', cellDates: true, cellStyles: true })
|
|
232
|
+
return workbook.SheetNames.map((name) => {
|
|
233
|
+
const sheet = workbook.Sheets[name]
|
|
234
|
+
const range = sheet?.['!ref'] ? XLSX.utils.decode_range(sheet['!ref']) : null
|
|
235
|
+
return {
|
|
236
|
+
name,
|
|
237
|
+
rows: buildSheetRows(sheet),
|
|
238
|
+
startCol: range?.s.c ?? 0,
|
|
239
|
+
endCol: range?.e.c ?? 0,
|
|
240
|
+
colWidths: range ? parseColWidths(sheet, range.s.c, range.e.c) : {},
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const buildSheetRows = (sheet?: XLSX.WorkSheet): ExcelPreviewCell[][] => {
|
|
246
|
+
if (!sheet || !sheet['!ref']) return []
|
|
247
|
+
const range = XLSX.utils.decode_range(sheet['!ref'])
|
|
248
|
+
const colWidths = parseColWidths(sheet, range.s.c, range.e.c)
|
|
249
|
+
const mergeStarts = new Map<string, { rowSpan: number; colSpan: number }>()
|
|
250
|
+
const covered = new Set<string>()
|
|
251
|
+
|
|
252
|
+
for (const merge of sheet['!merges'] || []) {
|
|
253
|
+
const rowSpan = merge.e.r - merge.s.r + 1
|
|
254
|
+
const colSpan = merge.e.c - merge.s.c + 1
|
|
255
|
+
mergeStarts.set(`${merge.s.r},${merge.s.c}`, { rowSpan, colSpan })
|
|
256
|
+
for (let r = merge.s.r; r <= merge.e.r; r++) {
|
|
257
|
+
for (let c = merge.s.c; c <= merge.e.c; c++) {
|
|
258
|
+
if (r !== merge.s.r || c !== merge.s.c) covered.add(`${r},${c}`)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const rows: ExcelPreviewCell[][] = []
|
|
264
|
+
for (let r = range.s.r; r <= range.e.r; r++) {
|
|
265
|
+
const row: ExcelPreviewCell[] = []
|
|
266
|
+
for (let c = range.s.c; c <= range.e.c; c++) {
|
|
267
|
+
if (covered.has(`${r},${c}`)) continue
|
|
268
|
+
const addr = XLSX.utils.encode_cell({ r, c })
|
|
269
|
+
const cellObj = sheet[addr]
|
|
270
|
+
const merge = mergeStarts.get(`${r},${c}`)
|
|
271
|
+
row.push({
|
|
272
|
+
value: formatCellValue(cellObj),
|
|
273
|
+
row: r,
|
|
274
|
+
col: c,
|
|
275
|
+
rowSpan: merge?.rowSpan,
|
|
276
|
+
colSpan: merge?.colSpan,
|
|
277
|
+
style: extractCellStyle(cellObj),
|
|
278
|
+
width: colWidths[c],
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
if (row.length > 0) rows.push(row)
|
|
282
|
+
}
|
|
283
|
+
return rows
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export const searchExcelSheets = (
|
|
287
|
+
sheets: ExcelPreviewSheet[],
|
|
288
|
+
query: string,
|
|
289
|
+
): ExcelSearchMatch[] => {
|
|
290
|
+
const keyword = query.trim().toLowerCase()
|
|
291
|
+
if (!keyword) return []
|
|
292
|
+
const matches: ExcelSearchMatch[] = []
|
|
293
|
+
sheets.forEach((sheet, sheetIndex) => {
|
|
294
|
+
for (const row of sheet.rows) {
|
|
295
|
+
for (const cell of row) {
|
|
296
|
+
const text = cell.value
|
|
297
|
+
if (text.toLowerCase().includes(keyword)) {
|
|
298
|
+
matches.push({ sheetIndex, row: cell.row, col: cell.col, text })
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
return matches
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export const cellKey = (sheetIndex: number, row: number, col: number) =>
|
|
307
|
+
`${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
|
+
}
|