fix: preserve retained formatting when aligning DOCX review text

This commit is contained in:
hy committed 2026-10-21 15:00:48 +08:00
1 parent 104534a3a3
commit fdb028d125
2 files changed
+95 -4

No files matched your search

@@ -311,6 +311,67 @@ const appendTokens = (paragraph: Element, selected: SelectedToken[]): void => {
}
}
/** Prefer retained formatting only when it keeps the same maximal text alignment. */
const diffTokens = (
before: InlineToken[],
after: InlineToken[]
): ReturnType<typeof sequenceDiff> => {
const oldKeys = before.map((token) => token.key),
newKeys = after.map((token) => token.key)
const plain = sequenceDiff(oldKeys, newKeys)
const score = (chunks: ReturnType<typeof sequenceDiff>): { text: number; format: number } => {
let oldIndex = 0,
newIndex = 0,
text = 0,
format = 0
for (const chunk of chunks) {
if (!chunk.added && !chunk.removed)
for (let index = 0; index < chunk.count; index++) {
text++
if (before[oldIndex + index]!.format === after[newIndex + index]!.format) format++
}
if (!chunk.added) oldIndex += chunk.count
if (!chunk.removed) newIndex += chunk.count
}
return { text, format }
}
const plainScore = score(plain)
if (plainScore.text === plainScore.format) return plain
const styled = sequenceDiff(
before.map((token) => JSON.stringify([token.key, token.format])),
after.map((token) => JSON.stringify([token.key, token.format]))
)
const candidate: ReturnType<typeof sequenceDiff> = []
let oldIndex = 0,
newIndex = 0,
oldStart = 0,
newStart = 0
const appendGap = (): void => {
// Re-align gaps as text so genuine format changes remain format-only changes.
for (const chunk of sequenceDiff(
oldKeys.slice(oldStart, oldIndex),
newKeys.slice(newStart, newIndex)
))
candidate.push(chunk)
}
for (const chunk of styled) {
if (!chunk.added && !chunk.removed) {
appendGap()
candidate.push({ value: newKeys.slice(newIndex, newIndex + chunk.count), count: chunk.count })
oldStart = oldIndex + chunk.count
newStart = newIndex + chunk.count
}
if (!chunk.added) oldIndex += chunk.count
if (!chunk.removed) newIndex += chunk.count
}
appendGap()
const candidateScore = score(candidate)
return candidateScore.text === plainScore.text && candidateScore.format > plainScore.format
? candidate
: plain
}
export type InlineResult = { element: Element; textChanged: boolean; formatChanged: boolean }
export const renderParagraphDiff = (
before: Element,
@@ -380,10 +441,7 @@ export const renderParagraphDiff = (
newIndex = 0,
textChanged = false,
formatChanged = paragraphFormatChanged
const chunks = sequenceDiff(
oldTokens.map((t) => t.key),
newTokens.map((t) => t.key)
)
const chunks = diffTokens(oldTokens, newTokens)
// Myers can emit insertion before deletion; replacements always show old before new.
for (let i = 0; i < chunks.length - 1; i++)
if (chunks[i]!.added && chunks[i + 1]!.removed)
+33
View File
@@ -114,6 +114,39 @@ describe('DOCX first/final net review', () => {
expect(result.stats).toEqual({ changeCount: 1, insertCount: 0, deleteCount: 0 })
expect(storyText(result.document)).toEqual(['前中后'])
})
it('aligns repeated text with the retained formatting when simplification removes an earlier occurrence', async () => {
const before =
'<w:p><w:r><w:t>本产品是一套面向社区公开资料整理与分享场景的 </w:t></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>公开资料整理工作台</w:t></w:r><w:r><w:t>,支持资料检索。</w:t></w:r></w:p>'
const after =
'<w:p><w:r><w:t>本产品是一套</w:t></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>公开资料整理工作台</w:t></w:r><w:r><w:t>,支持资料检索。</w:t></w:r></w:p>'
const result = await compare(before, after)
expect(storyText(result.document, false)).toEqual([
'本产品是一套面向社区公开资料整理与分享场景的 公开资料整理工作台,支持资料检索。'
])
expect(storyText(result.document, true)).toEqual([
'本产品是一套公开资料整理工作台,支持资料检索。'
])
expect(reviewRuns(result.document).filter((run) => run.kind === 'formatted')).toEqual([])
expect(
reviewRuns(result.document)
.filter((run) => run.kind === 'deleted')
.map((run) => run.text)
.join('')
).toBe('面向社区公开资料整理与分享场景的 ')
})
it('keeps real format changes when matching repeated formatting would remove unchanged text', async () => {
const result = await compare(
'<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>同</w:t></w:r><w:r><w:t>同</w:t></w:r></w:p>',
'<w:p><w:r><w:t>同</w:t></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>同</w:t></w:r></w:p>'
)
const runs = reviewRuns(result.document)
expect(runs.every((run) => run.kind === 'formatted')).toBe(true)
expect(runs.map((run) => run.text).join('')).toBe('同同')
expect(result.stats.insertCount).toBe(0)
expect(result.stats.deleteCount).toBe(0)
expect(storyText(result.document, false)).toEqual(['同同'])
expect(storyText(result.document, true)).toEqual(['同同'])
})
it('uses border when background has been removed', async () => {
const result = await compare(p('背景', '<w:shd w:fill="FFFF00"/>'), p('背景'))
expect(