/* ===================================================================== * richtext.ts — 结构化富文本(segments)的序列化/反序列化/渲染 * * 数据模型: * RichLine[] = 一组行,每行含若干 RichSegment * RichSegment = { text, bold?, italic?, color?, highlight?, ... } * * 与 content (string) 的关系: * - segments 存在时优先用于渲染 * - segmentsToPlain() 可降级为纯文本(AI context / 搜索 / 向后兼容) * - plainToSegments() 可从纯文本升级 * ===================================================================== */ import type { RichLine, RichSegment } from './types' /* ---------- 转义 ---------- */ function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>') } /* ---------- segments → HTML(渲染用) ---------- */ function segmentToHtml(seg: RichSegment): string { let html = escapeHtml(seg.text) if (!html) return '' // 嵌套标签(顺序:外层 → 内层) if (seg.code) html = `${html}` if (seg.sup) html = `${html}` if (seg.sub) html = `${html}` if (seg.strike) html = `${html}` if (seg.underline) html = `${html}` if (seg.italic) html = `${html}` if (seg.bold) html = `${html}` // 颜色 / 字号 / 高亮 / 链接 → 用 span 包裹 const styles: string[] = [] if (seg.color) styles.push(`color:${seg.color}`) if (seg.fontSize) styles.push(`font-size:${seg.fontSize}px`) if (seg.highlight) styles.push('background:rgba(245,158,11,.25);padding:0 .15em;border-radius:2px') if (seg.link) { const safeHref = /^(https?:|\/)/.test(seg.link) ? seg.link : '#' html = `${html}` } else if (styles.length) { html = `${html}` } return html } /** segments → 完整 HTML(多行,用
分隔) */ export function segmentsToHtml(lines: RichLine[]): string { if (!lines || !lines.length) return '' return lines .map(line => (line.segments || []).map(segmentToHtml).join('')) .join('
') } /* ---------- segments → 纯文本(AI context / 搜索 / 兼容) ---------- */ export function segmentsToPlain(lines: RichLine[]): string { if (!lines || !lines.length) return '' return lines .map(line => (line.segments || []).map(s => s.text || '').join('')) .join('\n') } /* ---------- 纯文本 → segments(升级) ---------- */ export function plainToSegments(text: string): RichLine[] { if (!text) return [] return text.split('\n').map(line => ({ segments: line ? [{ text: line }] : [] })) } /* ---------- Markdown → segments(从方案 A 无损迁移) ---------- */ const MD_PATTERNS: Array<{ regex: RegExp; parse: (m: string, inner: string) => Partial | null }> = [ { regex: /\*\*([^*]+)\*\*/, parse: (_m, inner) => ({ text: inner, bold: true }) }, { regex: /(? ({ text: inner, italic: true }) }, { regex: /~~([^~]+)~~/, parse: (_m, inner) => ({ text: inner, strike: true }) }, { regex: /==([^=]+)==/, parse: (_m, inner) => ({ text: inner, highlight: true }) }, { regex: /`([^`]+)`/, parse: (_m, inner) => ({ text: inner, code: true }) }, { regex: /\^([^\^\s][^\^]*?)\^/, parse: (_m, inner) => ({ text: inner, sup: true }) }, { regex: /(? ({ text: inner, sub: true }) } ] /** 把一行 Markdown 文本解析为 segments */ export function markdownLineToSegments(line: string): RichSegment[] { if (!line) return [] const segments: RichSegment[] = [] let remaining = line while (remaining.length > 0) { let earliestIdx = -1 let earliestMatch: RegExpMatchArray | null = null let earliestParser: typeof MD_PATTERNS[0]['parse'] | null = null for (const pat of MD_PATTERNS) { const m = remaining.match(pat.regex) if (m && m.index !== undefined && (earliestIdx === -1 || m.index < earliestIdx)) { earliestIdx = m.index earliestMatch = m earliestParser = pat.parse } } if (earliestIdx === -1 || !earliestMatch || !earliestParser) { // 无更多匹配,剩余全部为纯文本 segments.push({ text: remaining }) break } // 匹配前的纯文本 if (earliestIdx > 0) { segments.push({ text: remaining.slice(0, earliestIdx) }) } // 匹配的格式段 const parsed = earliestParser(earliestMatch[0], earliestMatch[1]) if (parsed) segments.push(parsed as RichSegment) remaining = remaining.slice(earliestIdx + earliestMatch[0].length) } return segments } /** 把多行 Markdown 文本解析为 RichLine[] */ export function markdownToSegments(text: string): RichLine[] { if (!text) return [] return text.split('\n').map(line => ({ segments: markdownLineToSegments(line) })) } /* ---------- 合并相邻相同 segment(压缩存储) ---------- */ function segEqual(a: RichSegment, b: RichSegment): boolean { return a.bold === b.bold && a.italic === b.italic && a.underline === b.underline && a.strike === b.strike && a.color === b.color && a.highlight === b.highlight && a.code === b.code && a.sup === b.sup && a.sub === b.sub && a.fontSize === b.fontSize && a.link === b.link } export function mergeSegments(lines: RichLine[]): RichLine[] { return lines.map(line => { const merged: RichSegment[] = [] for (const seg of line.segments || []) { const last = merged[merged.length - 1] if (last && segEqual(last, seg)) { last.text += seg.text } else { merged.push({ ...seg }) } } return { segments: merged } }) } /* ---------- 工具:判断是否有格式(至少一个 segment 有样式) ---------- */ export function hasFormatting(lines: RichLine[]): boolean { if (!lines || !lines.length) return false for (const line of lines) { for (const seg of line.segments || []) { if (seg.bold || seg.italic || seg.underline || seg.strike || seg.color || seg.highlight || seg.code || seg.sup || seg.sub || seg.fontSize || seg.link) { return true } } } return false } /* ---------- 工具:行首是否自带列表标记(圆点/编号,用于避免渲染层双重标记) ---------- */ /** 检测行首(允许空白)是否自带列表标记:圈号①-⑳ / 阿拉伯数字+点顿 / 圆点符号 / 中文数字+点顿 */ export function hasLineMarker(line: string): boolean { if (!line) return false return /^[\s]*(?:[①-⑳]|[((]?\d{1,2}[)).、.]|[-•·▪●○*]|[一二三四五六七八九十]+[、..])/.test(line) } /* ---------- 工具:安全化(AI 输出或外部数据 → 合法 segments) ---------- */ export function normSegments(input: any): RichLine[] | undefined { if (!input || !Array.isArray(input)) return undefined const lines: RichLine[] = [] for (const line of input) { if (!line || !Array.isArray(line.segments)) continue const segs: RichSegment[] = [] for (const seg of line.segments) { if (!seg || typeof seg !== 'object') continue const text = String(seg.text || '') if (!text) continue const out: RichSegment = { text } if (seg.bold) out.bold = true if (seg.italic) out.italic = true if (seg.underline) out.underline = true if (seg.strike) out.strike = true if (seg.highlight) out.highlight = true if (seg.code) out.code = true if (seg.sup) out.sup = true if (seg.sub) out.sub = true if (typeof seg.color === 'string') { // 白名单:主题键或合法 hex if (/^(primary|accent|text|muted)$/.test(seg.color) || /^#[0-9a-f]{3,8}$/i.test(seg.color)) { out.color = seg.color } } if (typeof seg.fontSize === 'number' && seg.fontSize >= 8 && seg.fontSize <= 200) { out.fontSize = seg.fontSize } if (typeof seg.link === 'string' && /^(https?:|\/)/.test(seg.link)) { out.link = seg.link } segs.push(out) } lines.push({ segments: segs }) } return lines.length ? lines : undefined }