Why: docx preview already sniffs ZIP magic so a text file renamed to .docx stays readable. xlsx and pptx loaders still fed those bytes to the parser and surfaced a raw library error. Evidence: npx tsc --noEmit and npx vitest run (40 tests) on this branch after merging origin/main. Legacy .xls/.ppt skip the sniff because they are not OOXML. Impact: pseudo xlsx/pptx render as text with a short note. Real ZIP workbooks and decks still use the existing parsers. Refs: Dev PR #96, task feature/preview-sniff-fallback Co-authored-by: Cursor <cursoragent@cursor.com>
125 lines
5.2 KiB
TypeScript
125 lines
5.2 KiB
TypeScript
// 预览能力装配(裁定 D1–D4):把重解析器经 configureFilePreviewLoaders 注入 @ui。
|
||
// 全部 dynamic import——对应格式首次预览时才下载 chunk,主 bundle 不含解析器。
|
||
// 链接预览 fetcher 走服务端 /api/url-preview(SSRF 防护在服务端执行端)。
|
||
import { configureFilePreviewLoaders, configureLinkPreviewFetcher } from '@ui'
|
||
import { marked } from 'marked'
|
||
import { auth } from './auth'
|
||
import type { LinkPreviewData } from '@ui'
|
||
|
||
const fetchBuf = async (url: string | undefined): Promise<ArrayBuffer> => {
|
||
const r = await fetch(url!)
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||
return r.arrayBuffer()
|
||
}
|
||
|
||
// docx/OOXML 是 ZIP 容器,真文件必以 PK 魔数开头(PK\x03\x04 常规,PK\x05\x06 空包)。
|
||
// 智能体生成侧可能写出"内容是文本的伪 docx",先嗅探再喂 mammoth:
|
||
// 非 ZIP 直接按文本/markdown 回退渲染,不再抛 JSZip 底层错误。
|
||
const isZipBytes = (buf: ArrayBuffer): boolean => {
|
||
if (buf.byteLength < 4) return false
|
||
const h = new Uint8Array(buf, 0, 4)
|
||
return h[0] === 0x50 && h[1] === 0x4b &&
|
||
((h[2] === 0x03 && h[3] === 0x04) || (h[2] === 0x05 && h[3] === 0x06))
|
||
}
|
||
|
||
const renderTextFallback = (el: HTMLElement, buf: ArrayBuffer, name: string | undefined, kindLabel: string) => {
|
||
const note = document.createElement('div')
|
||
note.className = 'ui-fp-note'
|
||
note.textContent = `「${name ?? '该文件'}」不是有效的 ${kindLabel} 文件(缺少 ZIP 结构),已按文本内容展示;需要正式格式请让智能体重新生成。`
|
||
const body = document.createElement('div')
|
||
body.className = 'doc-viewer'
|
||
body.innerHTML = marked.parse(new TextDecoder().decode(buf)) as string
|
||
el.innerHTML = ''
|
||
el.append(note, body)
|
||
}
|
||
|
||
const docxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => {
|
||
const buf = await fetchBuf(source.contentUrl)
|
||
if (!isZipBytes(buf)) {
|
||
renderTextFallback(el, buf, source.name, 'DOCX')
|
||
return
|
||
}
|
||
const mammoth = await import('mammoth')
|
||
const { value } = await mammoth.convertToHtml({ arrayBuffer: buf })
|
||
el.innerHTML = value
|
||
}
|
||
|
||
const xlsxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => {
|
||
const buf = await fetchBuf(source.contentUrl)
|
||
// .xls 是 OLE 复合文档,不是 ZIP;只对 OOXML(xlsx/xlsm,或未给文件名)做魔数嗅探。
|
||
if (!/\.xls$/i.test(source.name ?? '') && !isZipBytes(buf)) {
|
||
renderTextFallback(el, buf, source.name, 'XLSX')
|
||
return
|
||
}
|
||
const XLSX = await import('xlsx')
|
||
const wb = XLSX.read(buf, { type: 'array' })
|
||
el.innerHTML = ''
|
||
const tabs = document.createElement('div')
|
||
tabs.className = 'ui-fp-sheet-tabs'
|
||
const wrap = document.createElement('div')
|
||
wrap.className = 'ui-fp-sheet-wrap'
|
||
el.append(tabs, wrap)
|
||
wb.SheetNames.forEach((name, i) => {
|
||
const tab = document.createElement('button')
|
||
tab.className = `ui-fp-sheet-tab ${i === 0 ? 'on' : ''}`
|
||
tab.textContent = name
|
||
tab.onclick = () => {
|
||
tabs.querySelectorAll('.ui-fp-sheet-tab').forEach(t => t.classList.remove('on'))
|
||
tab.classList.add('on')
|
||
renderSheet(wrap, XLSX.utils.sheet_to_json<unknown[]>(wb.Sheets[name], { header: 1, defval: '' }))
|
||
}
|
||
tabs.append(tab)
|
||
if (i === 0) renderSheet(wrap, XLSX.utils.sheet_to_json<unknown[]>(wb.Sheets[name], { header: 1, defval: '' }))
|
||
})
|
||
}
|
||
|
||
function renderSheet(wrap: HTMLElement, rows: unknown[][]) {
|
||
wrap.innerHTML = ''
|
||
if (rows.length > 200) {
|
||
const note = document.createElement('div')
|
||
note.className = 'ui-fp-note'
|
||
note.textContent = `仅显示前 200 行(共 ${rows.length} 行)——完整内容请下载`
|
||
wrap.append(note)
|
||
}
|
||
const table = document.createElement('table')
|
||
table.className = 'ui-fp-sheet'
|
||
for (const row of rows.slice(0, 200)) {
|
||
const tr = document.createElement('tr')
|
||
for (const cell of row) {
|
||
const td = document.createElement('td')
|
||
td.textContent = cell == null ? '' : String(cell)
|
||
td.title = td.textContent
|
||
tr.append(td)
|
||
}
|
||
table.append(tr)
|
||
}
|
||
wrap.append(table)
|
||
}
|
||
|
||
const pptxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => {
|
||
const buf = await fetchBuf(source.contentUrl)
|
||
// .ppt 是老版二进制;OOXML(pptx/ppsx)才要求 ZIP 魔数。
|
||
if (!/\.ppt$/i.test(source.name ?? '') && !isZipBytes(buf)) {
|
||
renderTextFallback(el, buf, source.name, 'PPTX')
|
||
return
|
||
}
|
||
// pptx2html@0.3.4 的 module 入口引用了大小写错配的 ./txml(Linux 打包必炸),
|
||
// 按 D2 备选条款改用 pptx-preview(init(dom).preview(arrayBuffer) 全保真渲染)。
|
||
const { init } = await import('pptx-preview')
|
||
el.innerHTML = ''
|
||
const stage = document.createElement('div')
|
||
stage.className = 'ui-fp-slide-stage'
|
||
el.append(stage)
|
||
const previewer = init(stage, { width: stage.clientWidth || 900 })
|
||
await previewer.preview(buf)
|
||
}
|
||
|
||
export function wirePreviewCapabilities() {
|
||
configureFilePreviewLoaders({ docx: docxLoader, xlsx: xlsxLoader, pptx: pptxLoader })
|
||
configureLinkPreviewFetcher(async href => {
|
||
const r = await fetch(auth.withKey(`/api/url-preview?url=${encodeURIComponent(href)}`))
|
||
if (!r.ok) return { url: href, error: `HTTP ${r.status}` }
|
||
return (await r.json()) as LinkPreviewData
|
||
})
|
||
}
|