/**
* AI 聊天气泡:将 Markdown 转为 mp-html 可渲染的 HTML
* iOS App 的 rich-text 对 rpx、h1~h6(2em) 支持差,统一用 px + p 标签
*/
const FONT = {
body: 14,
bodySm: 12,
h1: 15,
h2: 14.5,
h3: 14
}
function px(n) {
return `${n}px`
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
}
/** 已是 HTML 片段时不再二次转义 */
function looksLikeHtml(s) {
return /<\/?[a-z][\s\S]*>/i.test(s)
}
/** iOS rich-text:h1~h6 改为 p+px,去掉 font/big 等会放大的标签 */
function normalizeAiHtml(html) {
let s = String(html)
const headingPx = [FONT.h1, FONT.h2, FONT.h3, FONT.h3, FONT.h3, FONT.h3]
for (let level = 1; level <= 6; level += 1) {
const size = headingPx[level - 1]
const re = new RegExp(`
${content}
` ) } s = s.replace(/]*>/gi, '').replace(/<\/font>/gi, '') s = s.replace(/]*>/gi, '').replace(/<\/big>/gi, '') return s } /** * @param {string} text 原始 Markdown 或纯文本 * @returns {string} HTML */ export function markdownToHtml(text) { if (text == null || text === '') { return '' } const raw = String(text) if (looksLikeHtml(raw)) { return normalizeAiHtml(raw) } const blocks = [] const re = /```([\s\S]*?)```/g let last = 0 let m while ((m = re.exec(raw)) !== null) { if (m.index > last) { blocks.push({ type: 'md', text: raw.slice(last, m.index) }) } blocks.push({ type: 'code', text: m[1] }) last = m.index + m[0].length } if (last < raw.length) { blocks.push({ type: 'md', text: raw.slice(last) }) } if (!blocks.length) { blocks.push({ type: 'md', text: raw }) } return blocks.map((b) => (b.type === 'code' ? formatCodeBlock(b.text) : formatMdBlock(b.text))).join('') } function formatCodeBlock(code) { const body = escapeHtml(code.replace(/^\n|\n$/g, '')) return `${body}`
}
function headingStyle(level) {
const size = level === 1 ? FONT.h1 : level === 2 ? FONT.h2 : FONT.h3
return `margin:8px 0 6px;font-size:${px(size)};font-weight:600;line-height:1.35;word-break:break-word;`
}
function formatMdBlock(text) {
const lines = text.split('\n')
const out = []
let i = 0
while (i < lines.length) {
const line = lines[i]
const trimmed = line.trim()
if (!trimmed) {
i += 1
continue
}
if (/^#{1,3}\s+/.test(trimmed)) {
const level = trimmed.match(/^(#+)/)[1].length
const content = inlineFormat(escapeHtml(trimmed.replace(/^#+\s+/, '')))
out.push(`${content}
`) i += 1 continue } if (/^[-*]\s+/.test(trimmed)) { const items = [] while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) { items.push(inlineFormat(escapeHtml(lines[i].trim().replace(/^[-*]\s+/, '')))) i += 1 } out.push( `${body}` ) continue } const para = [] while (i < lines.length && lines[i].trim()) { para.push(lines[i]) i += 1 } const body = inlineFormat(escapeHtml(para.join('\n'))).replace(/\n/g, '
${body}
` ) } return ( out.join('') || `${inlineFormat(escapeHtml(text))}
` ) } function inlineFormat(s) { return s .replace( /`([^`]+)`/g, `$1`
)
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
}
const bodyStyle = `font-size:${px(FONT.body)};line-height:1.55;word-break:break-word;`
/** mp-html tag-style(统一 px,避免 iOS rich-text 忽略 rpx) */
export const MP_HTML_TAG_STYLE = {
p: `margin:0 0 10px;${bodyStyle}`,
div: bodyStyle,
h1: `display:block;margin:8px 0 6px;font-size:${px(FONT.h1)};font-weight:600;line-height:1.35;`,
h2: `display:block;margin:8px 0 6px;font-size:${px(FONT.h2)};font-weight:600;line-height:1.35;`,
h3: `display:block;margin:8px 0 6px;font-size:${px(FONT.h3)};font-weight:600;line-height:1.35;`,
h4: `display:block;margin:8px 0 4px;font-size:${px(FONT.h3)};font-weight:600;line-height:1.35;`,
h5: `display:block;margin:6px 0 4px;font-size:${px(FONT.h3)};font-weight:600;line-height:1.35;`,
h6: `display:block;margin:6px 0 4px;font-size:${px(FONT.h3)};font-weight:600;line-height:1.35;`,
ul: `margin:6px 0 12px;padding-left:1.25em;font-size:${px(FONT.body)};`,
ol: `margin:6px 0 12px;padding-left:1.25em;font-size:${px(FONT.body)};`,
li: `margin:4px 0;line-height:1.5;font-size:${px(FONT.body)};`,
pre: `margin:8px 0;padding:10px 12px;border-radius:8px;background:#f6f8fa;overflow-x:auto;font-size:${px(FONT.bodySm)};`,
code: `font-family:monospace;font-size:${px(FONT.bodySm)};`,
blockquote: `margin:8px 0;padding:6px 12px;border-left:4px solid #22c55e;background:rgba(255,255,255,0.55);font-size:${px(
FONT.body
)};`,
a: `color:#16a34a;text-decoration:underline;font-size:${px(FONT.body)};`,
strong: 'font-weight:600;',
em: 'font-style:italic;',
big: `display:inline;font-size:${px(FONT.body)};`,
small: `display:inline;font-size:${px(FONT.bodySm)};`,
table: `border-collapse:collapse;margin:8px 0;font-size:${px(FONT.bodySm)};max-width:100%;`,
th: `border:1px solid #ddd4c8;padding:6px 10px;background:#f0ebe3;font-size:${px(FONT.bodySm)};`,
td: `border:1px solid #ddd4c8;padding:6px 10px;font-size:${px(FONT.bodySm)};`
}
export const MP_HTML_CONTAINER_STYLE = `display:block;width:100%;min-width:0;overflow:visible;line-height:1.55;font-size:${px(
FONT.body
)};color:#4a4542;word-break:break-word;-webkit-text-size-adjust:100%;text-size-adjust:100%;`