1
0

utils.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. import { marked, Renderer } from 'marked';
  2. import type { Tokens } from 'marked';
  3. import katex from 'katex';
  4. import DOMPurify from 'dompurify';
  5. const mdRenderer = new Renderer();
  6. const defaultLinkRenderer = mdRenderer.link.bind(mdRenderer);
  7. mdRenderer.link = (token: Tokens.Link): string => {
  8. const html = defaultLinkRenderer(token);
  9. return html.replace(/^<a\s/, '<a target="_blank" rel="noopener" ');
  10. };
  11. const renderLatexInHtml = (html: string): string => {
  12. // 匹配块级公式($$...$$)
  13. html = html.replace(/\$\$(.*?)\$\$/gs, (_match, formula) => {
  14. return katex.renderToString(formula.trim(), {
  15. displayMode: true,
  16. throwOnError: false,
  17. strict: false,
  18. trust: true,
  19. });
  20. });
  21. // 匹配行内公式($...$)
  22. html = html.replace(/\$(.*?)\$/g, (_match, formula) => {
  23. return katex.renderToString(formula.trim(), {
  24. displayMode: false,
  25. throwOnError: false,
  26. strict: false,
  27. });
  28. });
  29. return html;
  30. };
  31. const processFootnotes = (html: string, maxId?: number): string => {
  32. if (maxId === undefined) {
  33. // 无 citations 上下文,剥离所有 [^n] 标记
  34. return html.replace(/\[\^\d+\]/g, '');
  35. }
  36. // 有效编号范围 [1, maxId],范围外的剥离
  37. return html.replace(/\[\^(\d+)\]/g, (_match, n) => {
  38. const id = parseInt(n, 10);
  39. if (id >= 1 && id <= maxId) {
  40. return `<sup class="footnote-ref" data-n="${id}">[${n}]</sup>`;
  41. }
  42. return '';
  43. });
  44. };
  45. export const renderMarkdown = (
  46. text: string,
  47. icons?: { copy: string; download: string; preview: string; copySuccess: string },
  48. maxCitationId?: number
  49. ): string => {
  50. if (!text) return '';
  51. const processed = text.replace(/<br\s*\/?>/gi, ' \n');
  52. let html = marked.parse(processed, { renderer: mdRenderer }) as string;
  53. html = processFootnotes(html, maxCitationId);
  54. html = renderLatexInHtml(html);
  55. // Sanitize HTML to prevent XSS attacks
  56. html = DOMPurify.sanitize(html, {
  57. ADD_TAGS: ['img'],
  58. ADD_ATTR: ['target', 'data-action', 'data-table', 'data-icon', 'data-icon-success', 'data-n'],
  59. });
  60. // 原始 HTML 锚点不经过 marked link renderer,统一补 target/rel
  61. html = html.replace(/<a\b[^>]*>/gi, (tag) => {
  62. let attrs = '';
  63. if (!/\btarget\s*=/i.test(tag)) attrs += ' target="_blank"';
  64. if (!/\brel\s*=/i.test(tag)) attrs += ' rel="noopener"';
  65. return attrs ? tag.replace(/^<a\b/i, '<a' + attrs) : tag;
  66. });
  67. return icons ? wrapTables(html, icons) : html;
  68. };
  69. export const wrapTables = (html: string, icons: { copy: string; download: string; preview: string; copySuccess: string }): string => {
  70. return html.replace(/<table>([\s\S]*?)<\/table>/g, (match) => {
  71. const encodedTable = encodeURIComponent(match);
  72. return `<div class="markdown-table-wrapper">
  73. <div class="table-actions">
  74. <span class="table-action-btn" data-action="copy" data-table="${encodedTable}" data-icon="${icons.copy}" data-icon-success="${icons.copySuccess}" title="复制Markdown">
  75. <img src="${icons.copy}" width="14" height="14" />
  76. </span>
  77. <span class="table-action-btn" data-action="csv" data-table="${encodedTable}" title="下载CSV">
  78. <img src="${icons.download}" width="14" height="14" />
  79. </span>
  80. <span class="table-action-btn" data-action="preview" data-table="${encodedTable}" title="预览表格">
  81. <img src="${icons.preview}" width="14" height="14" />
  82. </span>
  83. </div>
  84. ${match}
  85. </div>`;
  86. });
  87. };
  88. export const tableHtmlToMarkdown = (tableHtml: string): string => {
  89. const parser = new DOMParser();
  90. const doc = parser.parseFromString(tableHtml, 'text/html');
  91. const table = doc.querySelector('table');
  92. if (!table) return tableHtml;
  93. const rows: string[][] = [];
  94. table.querySelectorAll('tr').forEach((tr) => {
  95. const cells: string[] = [];
  96. tr.querySelectorAll('th, td').forEach((cell) => {
  97. cells.push(cell.textContent?.trim() || '');
  98. });
  99. rows.push(cells);
  100. });
  101. if (rows.length === 0) return tableHtml;
  102. const colCount = Math.max(...rows.map((r) => r.length));
  103. const normalized = rows.map((r) => {
  104. while (r.length < colCount) r.push('');
  105. return r;
  106. });
  107. const lines: string[] = [];
  108. lines.push('| ' + normalized[0].join(' | ') + ' |');
  109. lines.push('| ' + normalized[0].map(() => '---').join(' | ') + ' |');
  110. for (let i = 1; i < normalized.length; i++) {
  111. lines.push('| ' + normalized[i].join(' | ') + ' |');
  112. }
  113. return lines.join('\n');
  114. };
  115. export const tableHtmlToCsv = (tableHtml: string): string => {
  116. const parser = new DOMParser();
  117. const doc = parser.parseFromString(tableHtml, 'text/html');
  118. const table = doc.querySelector('table');
  119. if (!table) return '';
  120. const rows: string[] = [];
  121. table.querySelectorAll('tr').forEach((tr) => {
  122. const cells: string[] = [];
  123. tr.querySelectorAll('th, td').forEach((cell) => {
  124. const text = (cell.textContent?.trim() || '').replace(/"/g, '""');
  125. cells.push(`"${text}"`);
  126. });
  127. rows.push(cells.join(','));
  128. });
  129. return '' + rows.join('\n');
  130. };
  131. export const formatFileSize = (bytes: number): string => {
  132. if (bytes < 1024) return bytes + ' B';
  133. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
  134. return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
  135. };
  136. export const isImageFile = (filename: string): boolean => /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff|tif)$/i.test(filename);
  137. export const isPdfFile = (filename: string): boolean => /\.pdf$/i.test(filename);
  138. export const isVideoFile = (filename: string): boolean => /\.(mp4|webm|ogg|mov|avi|mkv|flv|wmv|m4v)$/i.test(filename);
  139. export const isAudioFile = (filename: string): boolean => /\.(mp3|wav|ogg|aac|flac|wma|m4a|opus)$/i.test(filename);
  140. export const isMdFile = (filename: string): boolean => /\.md$/i.test(filename);
  141. export const isWordFile = (filename: string): boolean => /\.(doc|docx)$/i.test(filename);
  142. export const isOldDocFile = (filename: string): boolean => /\.doc$/i.test(filename) && !/\.docx$/i.test(filename);
  143. export const isExcelFile = (filename: string): boolean => /\.(xls|xlsx)$/i.test(filename);
  144. export const isCsvFile = (filename: string): boolean => /\.csv$/i.test(filename);
  145. export const isTextFile = (filename: string): boolean =>
  146. /\.(txt|md|json|csv|xml|yaml|yml|log|ini|toml|cfg|conf|env|gitignore|dockerignore|editorconfig|prettierrc|eslintrc|babelrc|properties|sh|bat|ps1|cmd|bash|zsh|fish|sql|graphql|proto|js|ts|jsx|tsx|vue|css|scss|sass|less|html|htm|svelte|py|java|kt|kts|c|cpp|h|hpp|cc|cxx|cs|go|rs|rb|php|swift|m|mm|r|R|lua|pl|pm|hs|ex|exs|erl|clj|scala|dart|zig|nim|v|sol|tf|hcl|gradle|cmake|makefile|mk)$/i.test(
  147. filename
  148. );
  149. export const getFileExtension = (filename: string): string => {
  150. const match = filename.match(/\.([^.]+)$/);
  151. return match ? `.${match[1]}`.toUpperCase() : '';
  152. };