Files
u-ppt/src/core/richtext.ts
T

220 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/* ---------- segments → HTML(渲染用) ---------- */
function segmentToHtml(seg: RichSegment): string {
let html = escapeHtml(seg.text)
if (!html) return ''
// 嵌套标签(顺序:外层 → 内层)
if (seg.code) html = `<code class="rt-code">${html}</code>`
if (seg.sup) html = `<sup>${html}</sup>`
if (seg.sub) html = `<sub>${html}</sub>`
if (seg.strike) html = `<del>${html}</del>`
if (seg.underline) html = `<u>${html}</u>`
if (seg.italic) html = `<em>${html}</em>`
if (seg.bold) html = `<strong>${html}</strong>`
// 颜色 / 字号 / 高亮 / 链接 → 用 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 = `<a href="${escapeHtml(safeHref)}" target="_blank" rel="noopener"${styles.length ? ` style="${styles.join(';')}"` : ''}>${html}</a>`
} else if (styles.length) {
html = `<span style="${styles.join(';')}">${html}</span>`
}
return html
}
/** segments → 完整 HTML(多行,用 <br> 分隔) */
export function segmentsToHtml(lines: RichLine[]): string {
if (!lines || !lines.length) return ''
return lines
.map(line => (line.segments || []).map(segmentToHtml).join(''))
.join('<br>')
}
/* ---------- 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<RichSegment> | null }> = [
{ regex: /\*\*([^*]+)\*\*/, parse: (_m, inner) => ({ text: inner, bold: true }) },
{ regex: /(?<!\*)\*([^*]+)\*(?!\*)/, parse: (_m, inner) => ({ 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: /(?<!~)~([^~\s][^~]*?)~(?!~)/, parse: (_m, inner) => ({ 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
}