// 预览能力装配(裁定 D1–D4):把重解析器经 configureFilePreviewLoaders 注入 @ui。 // 全部 dynamic import——对应格式首次预览时才下载 chunk,主 bundle 不含解析器。 // 链接预览 fetcher 走服务端 /api/url-preview(SSRF 防护在服务端执行端)。 import { configureFilePreviewLoaders, configureLinkPreviewFetcher, renderMarkdown, setSafeHTML, stripUnsafeLinks } from '@ui' import { auth } from './auth' import type { LinkPreviewData } from '@ui' import { fitPagesToWidth } from './previewfit' const fetchBuf = async (url: string | undefined): Promise => { 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' setSafeHTML(body, renderMarkdown(new TextDecoder().decode(buf), { breaks: true })) 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 } try { const { renderAsync } = await import('docx-preview') el.innerHTML = '' await renderAsync(buf, el, el, { className: 'docx', inWrapper: true, breakPages: true }) stripUnsafeLinks(el) return fitPagesToWidth(el, el) } catch (err) { console.error('[preview:docx] page render failed, falling back', source.name, err) } const mammoth = await import('mammoth') const { value } = await mammoth.convertToHtml({ arrayBuffer: buf }) el.innerHTML = '' const body = document.createElement('div') body.className = 'doc-viewer' setSafeHTML(body, value) el.append(body) } 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(wb.Sheets[name], { header: 1, defval: '' })) } tabs.append(tab) if (i === 0) renderSheet(wrap, XLSX.utils.sheet_to_json(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) stripUnsafeLinks(el) } 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 }) }