西藏巴青项目

chatMarkdown.js 7.4KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. /**
  2. * AI 聊天气泡:将 Markdown 转为 mp-html 可渲染的 HTML
  3. * iOS App 的 rich-text 对 rpx、h1~h6(2em) 支持差,统一用 px + p 标签
  4. */
  5. const FONT = {
  6. body: 14,
  7. bodySm: 12,
  8. h1: 15,
  9. h2: 14.5,
  10. h3: 14
  11. }
  12. function px(n) {
  13. return `${n}px`
  14. }
  15. function escapeHtml(s) {
  16. return String(s)
  17. .replace(/&/g, '&')
  18. .replace(/</g, '&lt;')
  19. .replace(/>/g, '&gt;')
  20. .replace(/"/g, '&quot;')
  21. }
  22. /** 已是 HTML 片段时不再二次转义 */
  23. function looksLikeHtml(s) {
  24. return /<\/?[a-z][\s\S]*>/i.test(s)
  25. }
  26. /** iOS rich-text:h1~h6 改为 p+px,去掉 font/big 等会放大的标签 */
  27. function normalizeAiHtml(html) {
  28. let s = String(html)
  29. const headingPx = [FONT.h1, FONT.h2, FONT.h3, FONT.h3, FONT.h3, FONT.h3]
  30. for (let level = 1; level <= 6; level += 1) {
  31. const size = headingPx[level - 1]
  32. const re = new RegExp(`<h${level}(\\s[^>]*)?>([\\s\\S]*?)</h${level}>`, 'gi')
  33. s = s.replace(
  34. re,
  35. (_, _attrs, content) =>
  36. `<p style="margin:8px 0 6px;font-size:${px(size)};font-weight:600;line-height:1.35;word-break:break-word;">${content}</p>`
  37. )
  38. }
  39. s = s.replace(/<font\b[^>]*>/gi, '<span>').replace(/<\/font>/gi, '</span>')
  40. s = s.replace(/<big\b[^>]*>/gi, '<span>').replace(/<\/big>/gi, '</span>')
  41. return s
  42. }
  43. /**
  44. * @param {string} text 原始 Markdown 或纯文本
  45. * @returns {string} HTML
  46. */
  47. export function markdownToHtml(text) {
  48. if (text == null || text === '') {
  49. return ''
  50. }
  51. const raw = String(text)
  52. if (looksLikeHtml(raw)) {
  53. return normalizeAiHtml(raw)
  54. }
  55. const blocks = []
  56. const re = /```([\s\S]*?)```/g
  57. let last = 0
  58. let m
  59. while ((m = re.exec(raw)) !== null) {
  60. if (m.index > last) {
  61. blocks.push({ type: 'md', text: raw.slice(last, m.index) })
  62. }
  63. blocks.push({ type: 'code', text: m[1] })
  64. last = m.index + m[0].length
  65. }
  66. if (last < raw.length) {
  67. blocks.push({ type: 'md', text: raw.slice(last) })
  68. }
  69. if (!blocks.length) {
  70. blocks.push({ type: 'md', text: raw })
  71. }
  72. return blocks.map((b) => (b.type === 'code' ? formatCodeBlock(b.text) : formatMdBlock(b.text))).join('')
  73. }
  74. function formatCodeBlock(code) {
  75. const body = escapeHtml(code.replace(/^\n|\n$/g, ''))
  76. return `<pre style="margin:8px 0;padding:10px 12px;border-radius:8px;background:#f6f8fa;overflow-x:auto;font-size:${px(
  77. FONT.bodySm
  78. )};line-height:1.45"><code>${body}</code></pre>`
  79. }
  80. function headingStyle(level) {
  81. const size = level === 1 ? FONT.h1 : level === 2 ? FONT.h2 : FONT.h3
  82. return `margin:8px 0 6px;font-size:${px(size)};font-weight:600;line-height:1.35;word-break:break-word;`
  83. }
  84. function formatMdBlock(text) {
  85. const lines = text.split('\n')
  86. const out = []
  87. let i = 0
  88. while (i < lines.length) {
  89. const line = lines[i]
  90. const trimmed = line.trim()
  91. if (!trimmed) {
  92. i += 1
  93. continue
  94. }
  95. if (/^#{1,3}\s+/.test(trimmed)) {
  96. const level = trimmed.match(/^(#+)/)[1].length
  97. const content = inlineFormat(escapeHtml(trimmed.replace(/^#+\s+/, '')))
  98. out.push(`<p style="${headingStyle(level)}">${content}</p>`)
  99. i += 1
  100. continue
  101. }
  102. if (/^[-*]\s+/.test(trimmed)) {
  103. const items = []
  104. while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
  105. items.push(inlineFormat(escapeHtml(lines[i].trim().replace(/^[-*]\s+/, ''))))
  106. i += 1
  107. }
  108. out.push(
  109. `<ul style="margin:6px 0 12px;padding-left:1.25em;font-size:${px(FONT.body)}">${items
  110. .map((li) => `<li style="margin:4px 0;line-height:1.5;font-size:${px(FONT.body)}">${li}</li>`)
  111. .join('')}</ul>`
  112. )
  113. continue
  114. }
  115. if (/^\d+\.\s+/.test(trimmed)) {
  116. const items = []
  117. while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
  118. items.push(inlineFormat(escapeHtml(lines[i].trim().replace(/^\d+\.\s+/, ''))))
  119. i += 1
  120. }
  121. out.push(
  122. `<ol style="margin:6px 0 12px;padding-left:1.25em;font-size:${px(FONT.body)}">${items
  123. .map((li) => `<li style="margin:4px 0;line-height:1.5;font-size:${px(FONT.body)}">${li}</li>`)
  124. .join('')}</ol>`
  125. )
  126. continue
  127. }
  128. if (/^>\s+/.test(trimmed)) {
  129. const quoteLines = []
  130. while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
  131. quoteLines.push(lines[i].trim().replace(/^>\s?/, ''))
  132. i += 1
  133. }
  134. const body = quoteLines.map((l) => inlineFormat(escapeHtml(l))).join('<br/>')
  135. out.push(
  136. `<blockquote style="margin:8px 0;padding:6px 12px;border-left:4px solid #22c55e;background:rgba(255,255,255,0.55);font-size:${px(
  137. FONT.body
  138. )}">${body}</blockquote>`
  139. )
  140. continue
  141. }
  142. const para = []
  143. while (i < lines.length && lines[i].trim()) {
  144. para.push(lines[i])
  145. i += 1
  146. }
  147. const body = inlineFormat(escapeHtml(para.join('\n'))).replace(/\n/g, '<br/>')
  148. out.push(
  149. `<p style="margin:0 0 10px;line-height:1.55;word-break:break-word;font-size:${px(FONT.body)}">${body}</p>`
  150. )
  151. }
  152. return (
  153. out.join('') ||
  154. `<p style="margin:0;line-height:1.55;font-size:${px(FONT.body)}">${inlineFormat(escapeHtml(text))}</p>`
  155. )
  156. }
  157. function inlineFormat(s) {
  158. return s
  159. .replace(
  160. /`([^`]+)`/g,
  161. `<code style="padding:2px 6px;border-radius:4px;background:rgba(0,0,0,0.06);font-size:${px(FONT.bodySm)}">$1</code>`
  162. )
  163. .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
  164. .replace(/\*([^*]+)\*/g, '<em>$1</em>')
  165. }
  166. const bodyStyle = `font-size:${px(FONT.body)};line-height:1.55;word-break:break-word;`
  167. /** mp-html tag-style(统一 px,避免 iOS rich-text 忽略 rpx) */
  168. export const MP_HTML_TAG_STYLE = {
  169. p: `margin:0 0 10px;${bodyStyle}`,
  170. div: bodyStyle,
  171. h1: `display:block;margin:8px 0 6px;font-size:${px(FONT.h1)};font-weight:600;line-height:1.35;`,
  172. h2: `display:block;margin:8px 0 6px;font-size:${px(FONT.h2)};font-weight:600;line-height:1.35;`,
  173. h3: `display:block;margin:8px 0 6px;font-size:${px(FONT.h3)};font-weight:600;line-height:1.35;`,
  174. h4: `display:block;margin:8px 0 4px;font-size:${px(FONT.h3)};font-weight:600;line-height:1.35;`,
  175. h5: `display:block;margin:6px 0 4px;font-size:${px(FONT.h3)};font-weight:600;line-height:1.35;`,
  176. h6: `display:block;margin:6px 0 4px;font-size:${px(FONT.h3)};font-weight:600;line-height:1.35;`,
  177. ul: `margin:6px 0 12px;padding-left:1.25em;font-size:${px(FONT.body)};`,
  178. ol: `margin:6px 0 12px;padding-left:1.25em;font-size:${px(FONT.body)};`,
  179. li: `margin:4px 0;line-height:1.5;font-size:${px(FONT.body)};`,
  180. pre: `margin:8px 0;padding:10px 12px;border-radius:8px;background:#f6f8fa;overflow-x:auto;font-size:${px(FONT.bodySm)};`,
  181. code: `font-family:monospace;font-size:${px(FONT.bodySm)};`,
  182. blockquote: `margin:8px 0;padding:6px 12px;border-left:4px solid #22c55e;background:rgba(255,255,255,0.55);font-size:${px(
  183. FONT.body
  184. )};`,
  185. a: `color:#16a34a;text-decoration:underline;font-size:${px(FONT.body)};`,
  186. strong: 'font-weight:600;',
  187. em: 'font-style:italic;',
  188. big: `display:inline;font-size:${px(FONT.body)};`,
  189. small: `display:inline;font-size:${px(FONT.bodySm)};`,
  190. table: `border-collapse:collapse;margin:8px 0;font-size:${px(FONT.bodySm)};max-width:100%;`,
  191. th: `border:1px solid #ddd4c8;padding:6px 10px;background:#f0ebe3;font-size:${px(FONT.bodySm)};`,
  192. td: `border:1px solid #ddd4c8;padding:6px 10px;font-size:${px(FONT.bodySm)};`
  193. }
  194. export const MP_HTML_CONTAINER_STYLE = `display:block;width:100%;min-width:0;overflow:visible;line-height:1.55;font-size:${px(
  195. FONT.body
  196. )};color:#4a4542;word-break:break-word;-webkit-text-size-adjust:100%;text-size-adjust:100%;`