data.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. import { resolveFileViewerDataSqlWasmUrl } from '@file-viewer/core/assets';
  2. import { createFileViewerTranslator } from '@file-viewer/core';
  3. import type {
  4. FileRenderContext,
  5. FileViewerRenderedInstance,
  6. } from '@file-viewer/core';
  7. declare global {
  8. interface Window {
  9. __FLYFISH_DATA_SQL_WASM_URL__?: string;
  10. }
  11. }
  12. interface DataPreview {
  13. title: string;
  14. summary: Array<{ label: string; value: string }>;
  15. rows?: Array<Record<string, unknown>>;
  16. text?: string;
  17. image?: string;
  18. fontFamily?: string;
  19. }
  20. const dataStyle = `
  21. .data-viewer{min-height:100%;padding:28px;background:#eef1f4;color:#132235}
  22. .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)}
  23. .data-header{padding:20px 24px;border-bottom:1px solid rgba(15,23,42,.08)}
  24. .data-header span{color:#0f766e;font-size:12px;font-weight:800}
  25. .data-header h2{margin:6px 0 0;font-size:24px}
  26. .data-summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1px;background:rgba(15,23,42,.08)}
  27. .data-summary div{min-width:0;padding:15px 18px;background:#f8fafc}
  28. .data-summary span{display:block;color:#64748b;font-size:12px}
  29. .data-summary strong{display:block;margin-top:5px;overflow:hidden;color:#132235;font-size:15px;text-overflow:ellipsis;white-space:nowrap}
  30. .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}
  31. .asset-image{padding:24px;border-top:1px solid rgba(15,23,42,.08);background:#f8fafc;text-align:center}
  32. .asset-image img{max-width:100%;max-height:70vh;box-shadow:0 10px 30px rgba(15,23,42,.16)}
  33. .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}
  34. .data-table-wrap{max-height:520px;overflow:auto;border-top:1px solid rgba(15,23,42,.08)}
  35. .data-table{width:100%;border-collapse:collapse;font-size:13px}
  36. .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}
  37. .data-table th{position:sticky;top:0;background:#f8fafc;color:#64748b;z-index:1}
  38. `;
  39. const fontMimeMap: Record<string, string> = {
  40. otf: 'font/otf',
  41. ttf: 'font/ttf',
  42. woff: 'font/woff',
  43. woff2: 'font/woff2',
  44. };
  45. type DataTranslator = ReturnType<typeof createFileViewerTranslator>;
  46. const createStyle = (documentRef: Document) => {
  47. const style = documentRef.createElement('style');
  48. style.textContent = dataStyle;
  49. return style;
  50. };
  51. const createElement = <K extends keyof HTMLElementTagNameMap>(
  52. documentRef: Document,
  53. tagName: K,
  54. className?: string,
  55. text?: string
  56. ) => {
  57. const element = documentRef.createElement(tagName);
  58. if (className) {
  59. element.className = className;
  60. }
  61. if (text !== undefined) {
  62. element.textContent = text;
  63. }
  64. return element;
  65. };
  66. const formatBytes = (value: number) => {
  67. if (value < 1024) {
  68. return `${value} B`;
  69. }
  70. if (value < 1024 * 1024) {
  71. return `${(value / 1024).toFixed(1)} KB`;
  72. }
  73. return `${(value / 1024 / 1024).toFixed(1)} MB`;
  74. };
  75. const makeRows = (rows: Array<Record<string, unknown>>) => {
  76. return rows.slice(0, 30).map(row => {
  77. const next: Record<string, unknown> = {};
  78. Object.entries(row).slice(0, 24).forEach(([key, value]) => {
  79. if (typeof value === 'bigint') {
  80. next[key] = value.toString();
  81. } else if (value instanceof Uint8Array) {
  82. next[key] = `[bytes:${value.byteLength}]`;
  83. } else if (value && typeof value === 'object') {
  84. next[key] = JSON.stringify(value).slice(0, 180);
  85. } else {
  86. next[key] = value;
  87. }
  88. });
  89. return next;
  90. });
  91. };
  92. const extractReadableText = (buffer: ArrayBuffer, max = 8000) => {
  93. const bytes = new Uint8Array(buffer);
  94. const ascii = Array.from(bytes.slice(0, Math.min(bytes.length, max)))
  95. .map(byte => (byte >= 32 && byte <= 126) || byte === 10 || byte === 13 || byte === 9
  96. ? String.fromCharCode(byte)
  97. : ' ')
  98. .join('')
  99. .replace(/[ \t]{3,}/g, ' ');
  100. return ascii.trim().slice(0, max);
  101. };
  102. const readMagic = (buffer: ArrayBuffer, length = 12) => {
  103. return String.fromCharCode(...new Uint8Array(buffer.slice(0, length)));
  104. };
  105. const getWindowSqlWasmOverride = (documentRef: Document) => {
  106. return documentRef.defaultView?.__FLYFISH_DATA_SQL_WASM_URL__ ||
  107. (typeof window !== 'undefined' ? window.__FLYFISH_DATA_SQL_WASM_URL__ : undefined);
  108. };
  109. const renderFont = async (
  110. documentRef: Document,
  111. buffer: ArrayBuffer,
  112. type: string,
  113. t: DataTranslator
  114. ): Promise<DataPreview> => {
  115. const family = `FlyfishPreviewFont-${Date.now()}-${Math.random().toString(36).slice(2)}`;
  116. const ownerWindow = documentRef.defaultView || (typeof window !== 'undefined' ? window : undefined);
  117. const FontFaceConstructor = ownerWindow?.FontFace || (typeof FontFace !== 'undefined' ? FontFace : undefined);
  118. if (!FontFaceConstructor) {
  119. throw new Error(t('data.error.fontFaceUnsupported'));
  120. }
  121. const face = new FontFaceConstructor(family, buffer);
  122. await face.load();
  123. documentRef.fonts?.add(face);
  124. return {
  125. title: t('data.title.font'),
  126. fontFamily: family,
  127. summary: [
  128. { label: t('data.label.format'), value: type.toUpperCase() },
  129. { label: t('data.label.size'), value: formatBytes(buffer.byteLength) },
  130. { label: t('data.label.rendering'), value: 'Browser FontFace API' },
  131. ],
  132. };
  133. };
  134. const renderSqlite = async (
  135. documentRef: Document,
  136. buffer: ArrayBuffer,
  137. context: FileRenderContext | undefined,
  138. t: DataTranslator
  139. ): Promise<DataPreview> => {
  140. const { default: initSqlJs } = await import('sql.js');
  141. const sqlWasmUrl = resolveFileViewerDataSqlWasmUrl(context?.options?.data, [
  142. getWindowSqlWasmOverride(documentRef),
  143. ], documentRef.baseURI);
  144. const SQL = await initSqlJs({ locateFile: () => sqlWasmUrl });
  145. const db = new SQL.Database(new Uint8Array(buffer));
  146. try {
  147. const tableResult = db.exec("select name, type from sqlite_master where type in ('table','view') and name not like 'sqlite_%' order by type, name");
  148. const tables = tableResult[0]?.values || [];
  149. const firstTable = String(tables[0]?.[0] || '');
  150. const rows = firstTable
  151. ? db.exec(`select * from "${firstTable.replace(/"/g, '""')}" limit 30`)[0]
  152. : null;
  153. return {
  154. title: t('data.title.sqlite'),
  155. summary: [
  156. { label: t('data.label.objects'), value: String(tables.length) },
  157. { label: t('data.label.sampleTable'), value: firstTable || '-' },
  158. { label: t('data.label.rendering'), value: 'sql.js WASM' },
  159. ],
  160. rows: rows
  161. ? makeRows((rows.values as unknown[][]).map((values: unknown[]) => Object.fromEntries(
  162. rows.columns.map((column: string, index: number) => [column, values[index]])
  163. )))
  164. : makeRows((tables as unknown[][]).map((value: unknown[]) => ({ name: value[0], type: value[1] }))),
  165. };
  166. } finally {
  167. db.close();
  168. }
  169. };
  170. const renderParquet = async (buffer: ArrayBuffer, t: DataTranslator): Promise<DataPreview> => {
  171. const { parquetMetadataAsync, parquetReadObjects } = await import('hyparquet');
  172. const file = {
  173. byteLength: buffer.byteLength,
  174. slice: (start: number, end?: number) => buffer.slice(start, end),
  175. };
  176. const metadata = await parquetMetadataAsync(file);
  177. const rows = await parquetReadObjects({ file, rowFormat: 'object', rowEnd: 30 });
  178. return {
  179. title: t('data.title.parquet'),
  180. summary: [
  181. { label: t('data.label.rows'), value: metadata.num_rows?.toString?.() || '-' },
  182. { label: t('data.label.columns'), value: String(metadata.schema?.filter(item => item.name).length || 0) },
  183. { label: t('data.label.rendering'), value: 'hyparquet' },
  184. ],
  185. rows: makeRows(rows),
  186. };
  187. };
  188. const renderAvro = async (buffer: ArrayBuffer, t: DataTranslator): Promise<DataPreview> => {
  189. const avro = await import('avsc/etc/browser/avsc.js');
  190. const decoder = (avro as any).createBlobDecoder(new Blob([buffer]));
  191. const rows: Array<Record<string, unknown>> = [];
  192. let schema = '';
  193. await new Promise<void>((resolve, reject) => {
  194. decoder.on('metadata', (type: any) => {
  195. schema = type?.toString?.() || '';
  196. });
  197. decoder.on('data', (value: Record<string, unknown>) => {
  198. if (rows.length < 30) {
  199. rows.push(value);
  200. }
  201. });
  202. decoder.on('end', resolve);
  203. decoder.on('error', reject);
  204. });
  205. return {
  206. title: t('data.title.avro'),
  207. summary: [
  208. { label: t('data.label.sampleRows'), value: String(rows.length) },
  209. { label: 'Schema', value: schema ? t('data.value.schemaRead') : t('data.value.schemaUnread') },
  210. { label: t('data.label.rendering'), value: 'avsc' },
  211. ],
  212. rows: makeRows(rows),
  213. text: schema.slice(0, 6000),
  214. };
  215. };
  216. const renderWasm = async (buffer: ArrayBuffer, t: DataTranslator): Promise<DataPreview> => {
  217. const module = await WebAssembly.compile(buffer.slice(0));
  218. const imports = WebAssembly.Module.imports(module);
  219. const exports = WebAssembly.Module.exports(module);
  220. return {
  221. title: t('data.title.wasm'),
  222. summary: [
  223. { label: t('data.label.imports'), value: String(imports.length) },
  224. { label: t('data.label.exports'), value: String(exports.length) },
  225. { label: t('data.label.rendering'), value: 'WebAssembly.Module' },
  226. ],
  227. rows: makeRows([
  228. ...imports.map(item => ({ kind: 'import', module: item.module, name: item.name, type: item.kind })),
  229. ...exports.map(item => ({ kind: 'export', module: '-', name: item.name, type: item.kind })),
  230. ]),
  231. };
  232. };
  233. const renderPostScriptLike = async (buffer: ArrayBuffer, type: string, t: DataTranslator): Promise<DataPreview> => ({
  234. title: type === 'eps' ? t('data.title.eps') : t('data.title.ai'),
  235. summary: [
  236. { label: t('data.label.magic'), value: readMagic(buffer).replace(/\s/g, ' ') },
  237. { label: t('data.label.size'), value: formatBytes(buffer.byteLength) },
  238. { label: t('data.label.note'), value: type === 'ai' ? t('data.note.aiSummary') : t('data.note.postscriptSummary') },
  239. ],
  240. text: extractReadableText(buffer),
  241. });
  242. const renderWebArchive = async (buffer: ArrayBuffer, t: DataTranslator): Promise<DataPreview> => ({
  243. title: t('data.title.webarchive'),
  244. summary: [
  245. { label: t('data.label.container'), value: readMagic(buffer).startsWith('bplist') ? 'Binary plist' : 'WebArchive' },
  246. { label: t('data.label.size'), value: formatBytes(buffer.byteLength) },
  247. { label: t('data.label.note'), value: t('data.note.webarchive') },
  248. ],
  249. text: extractReadableText(buffer),
  250. });
  251. const buildPreview = async (
  252. documentRef: Document,
  253. buffer: ArrayBuffer,
  254. type: string,
  255. context: FileRenderContext | undefined,
  256. t: DataTranslator
  257. ): Promise<DataPreview> => {
  258. if (type in fontMimeMap) {
  259. return renderFont(documentRef, buffer, type, t);
  260. }
  261. if (type === 'sqlite') {
  262. return renderSqlite(documentRef, buffer, context, t);
  263. }
  264. if (type === 'parquet') {
  265. return renderParquet(buffer, t);
  266. }
  267. if (type === 'avro') {
  268. return renderAvro(buffer, t);
  269. }
  270. if (type === 'wasm') {
  271. return renderWasm(buffer, t);
  272. }
  273. if (type === 'ai' || type === 'eps') {
  274. return renderPostScriptLike(buffer, type, t);
  275. }
  276. if (type === 'webarchive') {
  277. return renderWebArchive(buffer, t);
  278. }
  279. return {
  280. title: t('data.title.summary'),
  281. summary: [
  282. { label: t('data.label.format'), value: type.toUpperCase() },
  283. { label: t('data.label.size'), value: formatBytes(buffer.byteLength) },
  284. ],
  285. text: extractReadableText(buffer),
  286. };
  287. };
  288. const appendSummary = (
  289. documentRef: Document,
  290. parent: HTMLElement,
  291. summary: DataPreview['summary']
  292. ) => {
  293. const summaryRoot = createElement(documentRef, 'div', 'data-summary');
  294. summary.forEach(item => {
  295. const cell = createElement(documentRef, 'div');
  296. cell.append(
  297. createElement(documentRef, 'span', undefined, item.label),
  298. createElement(documentRef, 'strong', undefined, item.value)
  299. );
  300. summaryRoot.appendChild(cell);
  301. });
  302. parent.appendChild(summaryRoot);
  303. };
  304. const appendRows = (
  305. documentRef: Document,
  306. parent: HTMLElement,
  307. rows: Array<Record<string, unknown>> | undefined
  308. ) => {
  309. if (!rows?.length) {
  310. return;
  311. }
  312. const keys = Object.keys(rows[0]);
  313. const wrap = createElement(documentRef, 'div', 'data-table-wrap');
  314. const table = createElement(documentRef, 'table', 'data-table');
  315. const thead = createElement(documentRef, 'thead');
  316. const headRow = createElement(documentRef, 'tr');
  317. keys.forEach(key => {
  318. headRow.appendChild(createElement(documentRef, 'th', undefined, key));
  319. });
  320. thead.appendChild(headRow);
  321. const tbody = createElement(documentRef, 'tbody');
  322. rows.forEach(row => {
  323. const tr = createElement(documentRef, 'tr');
  324. keys.forEach(key => {
  325. tr.appendChild(createElement(documentRef, 'td', undefined, String(row[key] ?? '')));
  326. });
  327. tbody.appendChild(tr);
  328. });
  329. table.append(thead, tbody);
  330. wrap.appendChild(table);
  331. parent.appendChild(wrap);
  332. };
  333. const renderPreviewDom = (
  334. documentRef: Document,
  335. preview: DataPreview,
  336. type: string,
  337. t: DataTranslator
  338. ) => {
  339. const root = createElement(documentRef, 'div', 'data-viewer');
  340. const card = createElement(documentRef, 'section', 'data-card');
  341. const header = createElement(documentRef, 'header', 'data-header');
  342. header.append(
  343. createElement(documentRef, 'span', undefined, type.toUpperCase()),
  344. createElement(documentRef, 'h2', undefined, preview.title)
  345. );
  346. card.appendChild(header);
  347. appendSummary(documentRef, card, preview.summary);
  348. if (preview.fontFamily) {
  349. const fontPreview = createElement(documentRef, 'div', 'font-preview', t('data.font.sample'));
  350. fontPreview.style.fontFamily = preview.fontFamily;
  351. card.appendChild(fontPreview);
  352. }
  353. if (preview.image) {
  354. const imageWrap = createElement(documentRef, 'div', 'asset-image');
  355. const image = createElement(documentRef, 'img') as HTMLImageElement;
  356. image.src = preview.image;
  357. image.alt = t('data.image.alt');
  358. imageWrap.appendChild(image);
  359. card.appendChild(imageWrap);
  360. }
  361. if (preview.text) {
  362. card.appendChild(createElement(documentRef, 'pre', 'asset-text', preview.text));
  363. }
  364. appendRows(documentRef, card, preview.rows);
  365. root.appendChild(card);
  366. return root;
  367. };
  368. export default async function renderDataAsset(
  369. buffer: ArrayBuffer,
  370. target: HTMLDivElement,
  371. type = 'bin',
  372. context?: FileRenderContext
  373. ): Promise<FileViewerRenderedInstance> {
  374. const documentRef = target.ownerDocument || document;
  375. const normalizedType = type.toLowerCase();
  376. const t = createFileViewerTranslator(context?.options);
  377. if (normalizedType === 'ai' && readMagic(buffer, 5) === '%PDF-' && context?.renderNestedBuffer) {
  378. const rendered = await context.renderNestedBuffer(buffer, 'pdf', target, context);
  379. if (rendered) {
  380. return rendered;
  381. }
  382. }
  383. if (normalizedType === 'psd') {
  384. const { default: renderPsdAsset } = await import('./psd.js');
  385. return renderPsdAsset(buffer, target, context);
  386. }
  387. const preview = await buildPreview(documentRef, buffer, normalizedType, context, t);
  388. const root = renderPreviewDom(documentRef, preview, normalizedType, t);
  389. target.replaceChildren(createStyle(documentRef), root);
  390. return {
  391. $el: root,
  392. unmount() {
  393. target.replaceChildren();
  394. },
  395. };
  396. }