diff --git a/src/main/services/ai/human/tools/docx/review/inlineDiff.ts b/src/main/services/ai/human/tools/docx/review/inlineDiff.ts index 81e54f90..00b94d10 100644 --- a/src/main/services/ai/human/tools/docx/review/inlineDiff.ts +++ b/src/main/services/ai/human/tools/docx/review/inlineDiff.ts @@ -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 => { + const oldKeys = before.map((token) => token.key), + newKeys = after.map((token) => token.key) + const plain = sequenceDiff(oldKeys, newKeys) + const score = (chunks: ReturnType): { 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 = [] + 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) diff --git a/tests/case/unit/ai/docx-diff.test.ts b/tests/case/unit/ai/docx-diff.test.ts index a197efa1..e3bbd5e9 100644 --- a/tests/case/unit/ai/docx-diff.test.ts +++ b/tests/case/unit/ai/docx-diff.test.ts @@ -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 = + '本产品是一套面向社区公开资料整理与分享场景的 公开资料整理工作台,支持资料检索。' + const after = + '本产品是一套公开资料整理工作台,支持资料检索。' + 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( + '同同', + '同同' + ) + 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('背景', ''), p('背景')) expect(