| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431 |
- import { resolveFileViewerDataSqlWasmUrl } from '@file-viewer/core/assets';
- import { createFileViewerTranslator } from '@file-viewer/core';
- import type {
- FileRenderContext,
- FileViewerRenderedInstance,
- } from '@file-viewer/core';
- declare global {
- interface Window {
- __FLYFISH_DATA_SQL_WASM_URL__?: string;
- }
- }
- interface DataPreview {
- title: string;
- summary: Array<{ label: string; value: string }>;
- rows?: Array<Record<string, unknown>>;
- text?: string;
- image?: string;
- fontFamily?: string;
- }
- const dataStyle = `
- .data-viewer{min-height:100%;padding:28px;background:#eef1f4;color:#132235}
- .data-card{max-width:1080px;margin:0 auto;overflow:hidden;border:1px solid rgba(15,23,42,.08);border-radius:8px;background:#fff;box-shadow:0 18px 48px rgba(15,23,42,.12)}
- .data-header{padding:20px 24px;border-bottom:1px solid rgba(15,23,42,.08)}
- .data-header span{color:#0f766e;font-size:12px;font-weight:800}
- .data-header h2{margin:6px 0 0;font-size:24px}
- .data-summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1px;background:rgba(15,23,42,.08)}
- .data-summary div{min-width:0;padding:15px 18px;background:#f8fafc}
- .data-summary span{display:block;color:#64748b;font-size:12px}
- .data-summary strong{display:block;margin-top:5px;overflow:hidden;color:#132235;font-size:15px;text-overflow:ellipsis;white-space:nowrap}
- .font-preview{padding:34px 28px;border-top:1px solid rgba(15,23,42,.08);font-size:42px;line-height:1.45;word-break:break-word}
- .asset-image{padding:24px;border-top:1px solid rgba(15,23,42,.08);background:#f8fafc;text-align:center}
- .asset-image img{max-width:100%;max-height:70vh;box-shadow:0 10px 30px rgba(15,23,42,.16)}
- .asset-text{margin:0;padding:18px 24px;overflow:auto;border-top:1px solid rgba(15,23,42,.08);background:#111827;color:#e5e7eb;font-size:13px;line-height:1.7}
- .data-table-wrap{max-height:520px;overflow:auto;border-top:1px solid rgba(15,23,42,.08)}
- .data-table{width:100%;border-collapse:collapse;font-size:13px}
- .data-table th,.data-table td{max-width:260px;padding:10px 12px;border-bottom:1px solid rgba(15,23,42,.08);overflow:hidden;text-align:left;text-overflow:ellipsis;white-space:nowrap}
- .data-table th{position:sticky;top:0;background:#f8fafc;color:#64748b;z-index:1}
- `;
- const fontMimeMap: Record<string, string> = {
- otf: 'font/otf',
- ttf: 'font/ttf',
- woff: 'font/woff',
- woff2: 'font/woff2',
- };
- type DataTranslator = ReturnType<typeof createFileViewerTranslator>;
- const createStyle = (documentRef: Document) => {
- const style = documentRef.createElement('style');
- style.textContent = dataStyle;
- return style;
- };
- const createElement = <K extends keyof HTMLElementTagNameMap>(
- documentRef: Document,
- tagName: K,
- className?: string,
- text?: string
- ) => {
- const element = documentRef.createElement(tagName);
- if (className) {
- element.className = className;
- }
- if (text !== undefined) {
- element.textContent = text;
- }
- return element;
- };
- const formatBytes = (value: number) => {
- if (value < 1024) {
- return `${value} B`;
- }
- if (value < 1024 * 1024) {
- return `${(value / 1024).toFixed(1)} KB`;
- }
- return `${(value / 1024 / 1024).toFixed(1)} MB`;
- };
- const makeRows = (rows: Array<Record<string, unknown>>) => {
- return rows.slice(0, 30).map(row => {
- const next: Record<string, unknown> = {};
- Object.entries(row).slice(0, 24).forEach(([key, value]) => {
- if (typeof value === 'bigint') {
- next[key] = value.toString();
- } else if (value instanceof Uint8Array) {
- next[key] = `[bytes:${value.byteLength}]`;
- } else if (value && typeof value === 'object') {
- next[key] = JSON.stringify(value).slice(0, 180);
- } else {
- next[key] = value;
- }
- });
- return next;
- });
- };
- const extractReadableText = (buffer: ArrayBuffer, max = 8000) => {
- const bytes = new Uint8Array(buffer);
- const ascii = Array.from(bytes.slice(0, Math.min(bytes.length, max)))
- .map(byte => (byte >= 32 && byte <= 126) || byte === 10 || byte === 13 || byte === 9
- ? String.fromCharCode(byte)
- : ' ')
- .join('')
- .replace(/[ \t]{3,}/g, ' ');
- return ascii.trim().slice(0, max);
- };
- const readMagic = (buffer: ArrayBuffer, length = 12) => {
- return String.fromCharCode(...new Uint8Array(buffer.slice(0, length)));
- };
- const getWindowSqlWasmOverride = (documentRef: Document) => {
- return documentRef.defaultView?.__FLYFISH_DATA_SQL_WASM_URL__ ||
- (typeof window !== 'undefined' ? window.__FLYFISH_DATA_SQL_WASM_URL__ : undefined);
- };
- const renderFont = async (
- documentRef: Document,
- buffer: ArrayBuffer,
- type: string,
- t: DataTranslator
- ): Promise<DataPreview> => {
- const family = `FlyfishPreviewFont-${Date.now()}-${Math.random().toString(36).slice(2)}`;
- const ownerWindow = documentRef.defaultView || (typeof window !== 'undefined' ? window : undefined);
- const FontFaceConstructor = ownerWindow?.FontFace || (typeof FontFace !== 'undefined' ? FontFace : undefined);
- if (!FontFaceConstructor) {
- throw new Error(t('data.error.fontFaceUnsupported'));
- }
- const face = new FontFaceConstructor(family, buffer);
- await face.load();
- documentRef.fonts?.add(face);
- return {
- title: t('data.title.font'),
- fontFamily: family,
- summary: [
- { label: t('data.label.format'), value: type.toUpperCase() },
- { label: t('data.label.size'), value: formatBytes(buffer.byteLength) },
- { label: t('data.label.rendering'), value: 'Browser FontFace API' },
- ],
- };
- };
- const renderSqlite = async (
- documentRef: Document,
- buffer: ArrayBuffer,
- context: FileRenderContext | undefined,
- t: DataTranslator
- ): Promise<DataPreview> => {
- const { default: initSqlJs } = await import('sql.js');
- const sqlWasmUrl = resolveFileViewerDataSqlWasmUrl(context?.options?.data, [
- getWindowSqlWasmOverride(documentRef),
- ], documentRef.baseURI);
- const SQL = await initSqlJs({ locateFile: () => sqlWasmUrl });
- const db = new SQL.Database(new Uint8Array(buffer));
- try {
- const tableResult = db.exec("select name, type from sqlite_master where type in ('table','view') and name not like 'sqlite_%' order by type, name");
- const tables = tableResult[0]?.values || [];
- const firstTable = String(tables[0]?.[0] || '');
- const rows = firstTable
- ? db.exec(`select * from "${firstTable.replace(/"/g, '""')}" limit 30`)[0]
- : null;
- return {
- title: t('data.title.sqlite'),
- summary: [
- { label: t('data.label.objects'), value: String(tables.length) },
- { label: t('data.label.sampleTable'), value: firstTable || '-' },
- { label: t('data.label.rendering'), value: 'sql.js WASM' },
- ],
- rows: rows
- ? makeRows((rows.values as unknown[][]).map((values: unknown[]) => Object.fromEntries(
- rows.columns.map((column: string, index: number) => [column, values[index]])
- )))
- : makeRows((tables as unknown[][]).map((value: unknown[]) => ({ name: value[0], type: value[1] }))),
- };
- } finally {
- db.close();
- }
- };
- const renderParquet = async (buffer: ArrayBuffer, t: DataTranslator): Promise<DataPreview> => {
- const { parquetMetadataAsync, parquetReadObjects } = await import('hyparquet');
- const file = {
- byteLength: buffer.byteLength,
- slice: (start: number, end?: number) => buffer.slice(start, end),
- };
- const metadata = await parquetMetadataAsync(file);
- const rows = await parquetReadObjects({ file, rowFormat: 'object', rowEnd: 30 });
- return {
- title: t('data.title.parquet'),
- summary: [
- { label: t('data.label.rows'), value: metadata.num_rows?.toString?.() || '-' },
- { label: t('data.label.columns'), value: String(metadata.schema?.filter(item => item.name).length || 0) },
- { label: t('data.label.rendering'), value: 'hyparquet' },
- ],
- rows: makeRows(rows),
- };
- };
- const renderAvro = async (buffer: ArrayBuffer, t: DataTranslator): Promise<DataPreview> => {
- const avro = await import('avsc/etc/browser/avsc.js');
- const decoder = (avro as any).createBlobDecoder(new Blob([buffer]));
- const rows: Array<Record<string, unknown>> = [];
- let schema = '';
- await new Promise<void>((resolve, reject) => {
- decoder.on('metadata', (type: any) => {
- schema = type?.toString?.() || '';
- });
- decoder.on('data', (value: Record<string, unknown>) => {
- if (rows.length < 30) {
- rows.push(value);
- }
- });
- decoder.on('end', resolve);
- decoder.on('error', reject);
- });
- return {
- title: t('data.title.avro'),
- summary: [
- { label: t('data.label.sampleRows'), value: String(rows.length) },
- { label: 'Schema', value: schema ? t('data.value.schemaRead') : t('data.value.schemaUnread') },
- { label: t('data.label.rendering'), value: 'avsc' },
- ],
- rows: makeRows(rows),
- text: schema.slice(0, 6000),
- };
- };
- const renderWasm = async (buffer: ArrayBuffer, t: DataTranslator): Promise<DataPreview> => {
- const module = await WebAssembly.compile(buffer.slice(0));
- const imports = WebAssembly.Module.imports(module);
- const exports = WebAssembly.Module.exports(module);
- return {
- title: t('data.title.wasm'),
- summary: [
- { label: t('data.label.imports'), value: String(imports.length) },
- { label: t('data.label.exports'), value: String(exports.length) },
- { label: t('data.label.rendering'), value: 'WebAssembly.Module' },
- ],
- rows: makeRows([
- ...imports.map(item => ({ kind: 'import', module: item.module, name: item.name, type: item.kind })),
- ...exports.map(item => ({ kind: 'export', module: '-', name: item.name, type: item.kind })),
- ]),
- };
- };
- const renderPostScriptLike = async (buffer: ArrayBuffer, type: string, t: DataTranslator): Promise<DataPreview> => ({
- title: type === 'eps' ? t('data.title.eps') : t('data.title.ai'),
- summary: [
- { label: t('data.label.magic'), value: readMagic(buffer).replace(/\s/g, ' ') },
- { label: t('data.label.size'), value: formatBytes(buffer.byteLength) },
- { label: t('data.label.note'), value: type === 'ai' ? t('data.note.aiSummary') : t('data.note.postscriptSummary') },
- ],
- text: extractReadableText(buffer),
- });
- const renderWebArchive = async (buffer: ArrayBuffer, t: DataTranslator): Promise<DataPreview> => ({
- title: t('data.title.webarchive'),
- summary: [
- { label: t('data.label.container'), value: readMagic(buffer).startsWith('bplist') ? 'Binary plist' : 'WebArchive' },
- { label: t('data.label.size'), value: formatBytes(buffer.byteLength) },
- { label: t('data.label.note'), value: t('data.note.webarchive') },
- ],
- text: extractReadableText(buffer),
- });
- const buildPreview = async (
- documentRef: Document,
- buffer: ArrayBuffer,
- type: string,
- context: FileRenderContext | undefined,
- t: DataTranslator
- ): Promise<DataPreview> => {
- if (type in fontMimeMap) {
- return renderFont(documentRef, buffer, type, t);
- }
- if (type === 'sqlite') {
- return renderSqlite(documentRef, buffer, context, t);
- }
- if (type === 'parquet') {
- return renderParquet(buffer, t);
- }
- if (type === 'avro') {
- return renderAvro(buffer, t);
- }
- if (type === 'wasm') {
- return renderWasm(buffer, t);
- }
- if (type === 'ai' || type === 'eps') {
- return renderPostScriptLike(buffer, type, t);
- }
- if (type === 'webarchive') {
- return renderWebArchive(buffer, t);
- }
- return {
- title: t('data.title.summary'),
- summary: [
- { label: t('data.label.format'), value: type.toUpperCase() },
- { label: t('data.label.size'), value: formatBytes(buffer.byteLength) },
- ],
- text: extractReadableText(buffer),
- };
- };
- const appendSummary = (
- documentRef: Document,
- parent: HTMLElement,
- summary: DataPreview['summary']
- ) => {
- const summaryRoot = createElement(documentRef, 'div', 'data-summary');
- summary.forEach(item => {
- const cell = createElement(documentRef, 'div');
- cell.append(
- createElement(documentRef, 'span', undefined, item.label),
- createElement(documentRef, 'strong', undefined, item.value)
- );
- summaryRoot.appendChild(cell);
- });
- parent.appendChild(summaryRoot);
- };
- const appendRows = (
- documentRef: Document,
- parent: HTMLElement,
- rows: Array<Record<string, unknown>> | undefined
- ) => {
- if (!rows?.length) {
- return;
- }
- const keys = Object.keys(rows[0]);
- const wrap = createElement(documentRef, 'div', 'data-table-wrap');
- const table = createElement(documentRef, 'table', 'data-table');
- const thead = createElement(documentRef, 'thead');
- const headRow = createElement(documentRef, 'tr');
- keys.forEach(key => {
- headRow.appendChild(createElement(documentRef, 'th', undefined, key));
- });
- thead.appendChild(headRow);
- const tbody = createElement(documentRef, 'tbody');
- rows.forEach(row => {
- const tr = createElement(documentRef, 'tr');
- keys.forEach(key => {
- tr.appendChild(createElement(documentRef, 'td', undefined, String(row[key] ?? '')));
- });
- tbody.appendChild(tr);
- });
- table.append(thead, tbody);
- wrap.appendChild(table);
- parent.appendChild(wrap);
- };
- const renderPreviewDom = (
- documentRef: Document,
- preview: DataPreview,
- type: string,
- t: DataTranslator
- ) => {
- const root = createElement(documentRef, 'div', 'data-viewer');
- const card = createElement(documentRef, 'section', 'data-card');
- const header = createElement(documentRef, 'header', 'data-header');
- header.append(
- createElement(documentRef, 'span', undefined, type.toUpperCase()),
- createElement(documentRef, 'h2', undefined, preview.title)
- );
- card.appendChild(header);
- appendSummary(documentRef, card, preview.summary);
- if (preview.fontFamily) {
- const fontPreview = createElement(documentRef, 'div', 'font-preview', t('data.font.sample'));
- fontPreview.style.fontFamily = preview.fontFamily;
- card.appendChild(fontPreview);
- }
- if (preview.image) {
- const imageWrap = createElement(documentRef, 'div', 'asset-image');
- const image = createElement(documentRef, 'img') as HTMLImageElement;
- image.src = preview.image;
- image.alt = t('data.image.alt');
- imageWrap.appendChild(image);
- card.appendChild(imageWrap);
- }
- if (preview.text) {
- card.appendChild(createElement(documentRef, 'pre', 'asset-text', preview.text));
- }
- appendRows(documentRef, card, preview.rows);
- root.appendChild(card);
- return root;
- };
- export default async function renderDataAsset(
- buffer: ArrayBuffer,
- target: HTMLDivElement,
- type = 'bin',
- context?: FileRenderContext
- ): Promise<FileViewerRenderedInstance> {
- const documentRef = target.ownerDocument || document;
- const normalizedType = type.toLowerCase();
- const t = createFileViewerTranslator(context?.options);
- if (normalizedType === 'ai' && readMagic(buffer, 5) === '%PDF-' && context?.renderNestedBuffer) {
- const rendered = await context.renderNestedBuffer(buffer, 'pdf', target, context);
- if (rendered) {
- return rendered;
- }
- }
- if (normalizedType === 'psd') {
- const { default: renderPsdAsset } = await import('./psd.js');
- return renderPsdAsset(buffer, target, context);
- }
- const preview = await buildPreview(documentRef, buffer, normalizedType, context, t);
- const root = renderPreviewDom(documentRef, preview, normalizedType, t);
- target.replaceChildren(createStyle(documentRef), root);
- return {
- $el: root,
- unmount() {
- target.replaceChildren();
- },
- };
- }
|