|
|
@@ -480,17 +480,19 @@ function renderStructuredDocument(structuredDoc) {
|
|
|
// 将所有元素合并
|
|
|
const allElements = []
|
|
|
|
|
|
+ // 从 blocks 中提取实体映射(按文本内容匹配)
|
|
|
+ const entityMap = buildEntityMap(blocks)
|
|
|
+
|
|
|
// 检查 paragraphs 是否有 runs(带格式信息)
|
|
|
const hasParagraphsWithRuns = paragraphs.some(p => p.runs && p.runs.length > 0)
|
|
|
|
|
|
- // 优先使用 paragraphs(带格式),如果有 runs 的话
|
|
|
if (hasParagraphsWithRuns) {
|
|
|
- // 使用 paragraphs 渲染(保留格式)
|
|
|
+ // 使用 paragraphs 渲染(保留格式 + 合并实体高亮)
|
|
|
paragraphs.forEach(para => {
|
|
|
allElements.push({
|
|
|
type: 'paragraph',
|
|
|
index: para.index,
|
|
|
- html: renderParagraphWithRuns(para)
|
|
|
+ html: renderParagraphWithRunsAndEntities(para, entityMap)
|
|
|
})
|
|
|
})
|
|
|
} else if (blocks.length > 0) {
|
|
|
@@ -529,63 +531,247 @@ function renderStructuredDocument(structuredDoc) {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 渲染带格式的段落(使用 runs)
|
|
|
+ * 从 blocks 中构建实体映射
|
|
|
+ * 返回 { entityText: { entityId, entityType, confirmed } }
|
|
|
+ */
|
|
|
+function buildEntityMap(blocks) {
|
|
|
+ const entityMap = new Map()
|
|
|
+
|
|
|
+ blocks.forEach(block => {
|
|
|
+ if (!block.elements) return
|
|
|
+
|
|
|
+ block.elements.forEach(el => {
|
|
|
+ if (el.type === 'entity' && el.entityText) {
|
|
|
+ // 使用实体文本作为 key(可能有多个相同文本的实体)
|
|
|
+ if (!entityMap.has(el.entityText)) {
|
|
|
+ entityMap.set(el.entityText, [])
|
|
|
+ }
|
|
|
+ entityMap.get(el.entityText).push({
|
|
|
+ entityId: el.entityId,
|
|
|
+ entityType: el.entityType,
|
|
|
+ confirmed: el.confirmed
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ return entityMap
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 渲染带格式和实体高亮的段落
|
|
|
+ */
|
|
|
+function renderParagraphWithRunsAndEntities(para, entityMap) {
|
|
|
+ if (!para.runs || para.runs.length === 0) {
|
|
|
+ // 没有 runs,使用纯文本
|
|
|
+ const content = highlightEntitiesInText(para.content || '', entityMap)
|
|
|
+ return wrapWithParagraphTag(content, para.type, para.style)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 渲染每个 run,同时应用实体高亮
|
|
|
+ const runsHtml = para.runs.map(run => renderTextRunWithEntities(run, entityMap)).join('')
|
|
|
+ return wrapWithParagraphTag(runsHtml, para.type, para.style)
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 渲染带格式的段落(使用 runs)- 保留兼容
|
|
|
*/
|
|
|
function renderParagraphWithRuns(para) {
|
|
|
if (!para.runs || para.runs.length === 0) {
|
|
|
- // 没有 runs,使用纯文本(将换行符转为 <br>)
|
|
|
const content = escapeHtml(para.content || '').replace(/\n/g, '<br>')
|
|
|
return wrapWithParagraphTag(content, para.type, para.style)
|
|
|
}
|
|
|
|
|
|
- // 渲染每个 run
|
|
|
const runsHtml = para.runs.map(run => renderTextRun(run)).join('')
|
|
|
return wrapWithParagraphTag(runsHtml, para.type, para.style)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 渲染单个文本片段(Run)
|
|
|
+ * 渲染单个文本片段(Run)并高亮实体
|
|
|
*/
|
|
|
-function renderTextRun(run) {
|
|
|
+function renderTextRunWithEntities(run, entityMap) {
|
|
|
if (!run || !run.text) return ''
|
|
|
|
|
|
- // 转义 HTML 并将换行符转换为 <br>
|
|
|
- let text = escapeHtml(run.text).replace(/\n/g, '<br>')
|
|
|
+ // 先在文本中查找并高亮实体
|
|
|
+ const highlightedText = highlightEntitiesInText(run.text, entityMap)
|
|
|
+
|
|
|
+ // 如果文本被实体高亮处理过(包含 span 标签),需要特殊处理样式
|
|
|
+ const hasEntityHighlight = highlightedText.includes('entity-highlight')
|
|
|
+
|
|
|
+ // 构建样式
|
|
|
+ const styles = buildRunStyles(run)
|
|
|
+
|
|
|
+ // 如果没有样式,直接返回高亮后的文本
|
|
|
+ if (styles.length === 0) {
|
|
|
+ return highlightedText.replace(/\n/g, '<br>')
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果有实体高亮,需要用 span 包裹整体样式
|
|
|
+ if (hasEntityHighlight) {
|
|
|
+ return `<span style="${styles.join(';')}">${highlightedText.replace(/\n/g, '<br>')}</span>`
|
|
|
+ }
|
|
|
+
|
|
|
+ // 普通文本,处理换行并应用样式
|
|
|
+ const text = escapeHtml(run.text).replace(/\n/g, '<br>')
|
|
|
+
|
|
|
+ // 上下标特殊处理
|
|
|
+ if (run.verticalAlign === 'superscript') {
|
|
|
+ return `<sup style="${styles.join(';')}">${text}</sup>`
|
|
|
+ } else if (run.verticalAlign === 'subscript') {
|
|
|
+ return `<sub style="${styles.join(';')}">${text}</sub>`
|
|
|
+ }
|
|
|
+
|
|
|
+ return `<span style="${styles.join(';')}">${text}</span>`
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 在文本中查找并高亮实体
|
|
|
+ */
|
|
|
+function highlightEntitiesInText(text, entityMap) {
|
|
|
+ if (!text || !entityMap || entityMap.size === 0) {
|
|
|
+ return escapeHtml(text || '')
|
|
|
+ }
|
|
|
+
|
|
|
+ // 按实体文本长度降序排序(优先匹配长的)
|
|
|
+ const sortedEntities = Array.from(entityMap.keys()).sort((a, b) => b.length - a.length)
|
|
|
+
|
|
|
+ let result = text
|
|
|
+ const replacements = []
|
|
|
+
|
|
|
+ // 找出所有需要替换的位置
|
|
|
+ for (const entityText of sortedEntities) {
|
|
|
+ const entities = entityMap.get(entityText)
|
|
|
+ if (!entities || entities.length === 0) continue
|
|
|
+
|
|
|
+ let searchStart = 0
|
|
|
+ let entityIndex = 0
|
|
|
+
|
|
|
+ while (true) {
|
|
|
+ const pos = result.indexOf(entityText, searchStart)
|
|
|
+ if (pos === -1) break
|
|
|
+
|
|
|
+ // 获取对应的实体信息(循环使用)
|
|
|
+ const entity = entities[entityIndex % entities.length]
|
|
|
+
|
|
|
+ replacements.push({
|
|
|
+ start: pos,
|
|
|
+ end: pos + entityText.length,
|
|
|
+ text: entityText,
|
|
|
+ entity: entity
|
|
|
+ })
|
|
|
+
|
|
|
+ searchStart = pos + entityText.length
|
|
|
+ entityIndex++
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 按位置排序,从后往前替换(避免位置偏移)
|
|
|
+ replacements.sort((a, b) => b.start - a.start)
|
|
|
+
|
|
|
+ // 检查重叠,移除被包含的替换
|
|
|
+ const finalReplacements = []
|
|
|
+ for (const rep of replacements) {
|
|
|
+ const hasOverlap = finalReplacements.some(
|
|
|
+ existing => rep.start < existing.end && rep.end > existing.start
|
|
|
+ )
|
|
|
+ if (!hasOverlap) {
|
|
|
+ finalReplacements.push(rep)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 执行替换
|
|
|
+ for (const rep of finalReplacements) {
|
|
|
+ const before = result.substring(0, rep.start)
|
|
|
+ const after = result.substring(rep.end)
|
|
|
+ const highlighted = renderEntityHighlight(rep.text, rep.entity)
|
|
|
+ result = before + highlighted + after
|
|
|
+ }
|
|
|
+
|
|
|
+ // 对非实体部分进行 HTML 转义
|
|
|
+ // 由于实体部分已经包含 HTML,需要分段处理
|
|
|
+ return escapeNonEntityText(result)
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 转义非实体部分的文本
|
|
|
+ */
|
|
|
+function escapeNonEntityText(text) {
|
|
|
+ // 分割出实体标签和普通文本
|
|
|
+ const parts = text.split(/(<span class="entity-highlight[^>]*>.*?<\/span>)/g)
|
|
|
+
|
|
|
+ return parts.map(part => {
|
|
|
+ if (part.startsWith('<span class="entity-highlight')) {
|
|
|
+ return part // 保留实体标签
|
|
|
+ }
|
|
|
+ return escapeHtml(part) // 转义普通文本
|
|
|
+ }).join('')
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 渲染实体高亮标签
|
|
|
+ */
|
|
|
+function renderEntityHighlight(text, entity) {
|
|
|
+ const cssClass = getEntityCssClass(entity.entityType)
|
|
|
+ const confirmedMark = entity.confirmed ? ' ✓' : ''
|
|
|
+
|
|
|
+ return `<span class="${cssClass}" ` +
|
|
|
+ `data-entity-id="${entity.entityId || ''}" ` +
|
|
|
+ `data-type="${entity.entityType || ''}" ` +
|
|
|
+ `onclick="showEntityEditModal(event,'${entity.entityId || ''}')" ` +
|
|
|
+ `contenteditable="false">${escapeHtml(text)}${confirmedMark}</span>`
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取实体类型对应的 CSS 类
|
|
|
+ */
|
|
|
+function getEntityCssClass(entityType) {
|
|
|
+ const typeMap = {
|
|
|
+ 'PERSON': 'entity-highlight person',
|
|
|
+ 'ORG': 'entity-highlight org',
|
|
|
+ 'ORGANIZATION': 'entity-highlight org',
|
|
|
+ 'LOC': 'entity-highlight location',
|
|
|
+ 'LOCATION': 'entity-highlight location',
|
|
|
+ 'GPE': 'entity-highlight location',
|
|
|
+ 'DATE': 'entity-highlight date',
|
|
|
+ 'TIME': 'entity-highlight date',
|
|
|
+ 'MONEY': 'entity-highlight data',
|
|
|
+ 'NUMBER': 'entity-highlight data',
|
|
|
+ 'PERCENT': 'entity-highlight data',
|
|
|
+ 'DATA': 'entity-highlight data',
|
|
|
+ 'CONCEPT': 'entity-highlight concept',
|
|
|
+ 'PRODUCT': 'entity-highlight product',
|
|
|
+ 'EVENT': 'entity-highlight event'
|
|
|
+ }
|
|
|
+ return typeMap[entityType?.toUpperCase()] || 'entity-highlight entity'
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 构建 Run 的 CSS 样式数组
|
|
|
+ */
|
|
|
+function buildRunStyles(run) {
|
|
|
const styles = []
|
|
|
|
|
|
- // 字体
|
|
|
if (run.fontFamily) {
|
|
|
styles.push(`font-family:${run.fontFamily}`)
|
|
|
}
|
|
|
-
|
|
|
- // 字号
|
|
|
if (run.fontSize && run.fontSize > 0) {
|
|
|
styles.push(`font-size:${run.fontSize}pt`)
|
|
|
}
|
|
|
-
|
|
|
- // 颜色
|
|
|
if (run.color) {
|
|
|
const color = run.color.startsWith('#') ? run.color : `#${run.color}`
|
|
|
styles.push(`color:${color}`)
|
|
|
}
|
|
|
-
|
|
|
- // 高亮/背景色
|
|
|
if (run.highlightColor) {
|
|
|
const bgColor = getHighlightColor(run.highlightColor)
|
|
|
styles.push(`background-color:${bgColor}`)
|
|
|
}
|
|
|
-
|
|
|
- // 加粗
|
|
|
if (run.bold) {
|
|
|
styles.push('font-weight:bold')
|
|
|
}
|
|
|
-
|
|
|
- // 斜体
|
|
|
if (run.italic) {
|
|
|
styles.push('font-style:italic')
|
|
|
}
|
|
|
|
|
|
- // 下划线和删除线
|
|
|
const textDecorations = []
|
|
|
if (run.underline && run.underline !== 'none') {
|
|
|
const underlineStyle = run.underline === 'double' ? 'double' :
|
|
|
@@ -601,11 +787,24 @@ function renderTextRun(run) {
|
|
|
styles.push(`text-decoration:${textDecorations.join(' ')}`)
|
|
|
}
|
|
|
|
|
|
+ return styles
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 渲染单个文本片段(Run)- 保留兼容
|
|
|
+ */
|
|
|
+function renderTextRun(run) {
|
|
|
+ if (!run || !run.text) return ''
|
|
|
+
|
|
|
+ // 转义 HTML 并将换行符转换为 <br>
|
|
|
+ let text = escapeHtml(run.text).replace(/\n/g, '<br>')
|
|
|
+ const styles = buildRunStyles(run)
|
|
|
+
|
|
|
// 上下标
|
|
|
if (run.verticalAlign === 'superscript') {
|
|
|
- return `<sup>${text}</sup>`
|
|
|
+ return styles.length > 0 ? `<sup style="${styles.join(';')}">${text}</sup>` : `<sup>${text}</sup>`
|
|
|
} else if (run.verticalAlign === 'subscript') {
|
|
|
- return `<sub>${text}</sub>`
|
|
|
+ return styles.length > 0 ? `<sub style="${styles.join(';')}">${text}</sub>` : `<sub>${text}</sub>`
|
|
|
}
|
|
|
|
|
|
// 如果没有样式,直接返回文本
|