/* =====================================================================
* 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
}