using System; using System.Collections.Generic; using System.Drawing; using System.Drawing.Drawing2D; using System.Drawing.Imaging; using System.Globalization; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using System.Threading; using DCIT.Core.Models; using DCIT.Core.Utilities; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using A = DocumentFormat.OpenXml.Drawing; using C = DocumentFormat.OpenXml.Drawing.Charts; using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; using M = DocumentFormat.OpenXml.Math; using Svg; using V = DocumentFormat.OpenXml.Vml; using W = DocumentFormat.OpenXml.Wordprocessing; namespace DCIT.Core.Services { public class DocumentProcessingService : IDocumentProcessingService { private static readonly string[] SupportedRasterExtensions = { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff" }; private static readonly string[] SupportedNonBitmapExtensions = { ".wmf", ".emf", ".svg" }; private static readonly string[] SupportedVisioPackageExtensions = { ".vsd", ".vsdx", ".vsdm" }; private static readonly string[] BrokenFieldResultMarkers = { "错误!未找到引用源", "错误!未找到引用源", "错误! 未找到引用源", "错误! 未找到引用源", "错误!书签未定义", "错误!书签未定义", "Error! Reference source not found", "Error! Bookmark not defined", }; private const string SupportedDiffVersion = "1.0"; private const string SupportedBmpCodecVersion = "1.0"; private const int WorkingDocumentOpenRetryCount = 5; private const int WorkingDocumentOpenRetryDelayMilliseconds = 150; private const double NoiseTargetPixelRatio = 0.55d; private const int NoiseCellSize = 16; private const int BrokenFieldResultScanLimit = 50; private readonly DiffSerializationService _diffSerializationService; private readonly IDocConversionService _docConversionService; private readonly IDocumentFieldUpdateService _documentFieldUpdateService; private readonly IFontValidationService _fontValidationService; public DocumentProcessingService(DiffSerializationService diffSerializationService) : this(diffSerializationService, new OfficeDocConversionService(), null, new FontValidationService()) { } public DocumentProcessingService(DiffSerializationService diffSerializationService, IDocConversionService docConversionService) : this(diffSerializationService, docConversionService, docConversionService as IDocumentFieldUpdateService, new FontValidationService()) { } public DocumentProcessingService( DiffSerializationService diffSerializationService, IDocConversionService docConversionService, IDocumentFieldUpdateService documentFieldUpdateService) : this(diffSerializationService, docConversionService, documentFieldUpdateService, new FontValidationService()) { } public DocumentProcessingService( DiffSerializationService diffSerializationService, IDocConversionService docConversionService, IDocumentFieldUpdateService documentFieldUpdateService, IFontValidationService fontValidationService) { if (diffSerializationService == null) { throw new ArgumentNullException("diffSerializationService"); } if (fontValidationService == null) { throw new ArgumentNullException("fontValidationService"); } _diffSerializationService = diffSerializationService; _docConversionService = docConversionService; _documentFieldUpdateService = documentFieldUpdateService; _fontValidationService = fontValidationService; } public FileProcessResult Replace(ReplaceFileRequest request, IProgress progress, CancellationToken cancellationToken) { if (request == null) { throw new ArgumentNullException("request"); } var sourceExtension = NormalizeWordExtension(request.SourcePath); var outputExtension = NormalizeWordExtension(request.OutputPath); EnsureDirectoryForFile(request.OutputPath); EnsureDirectoryForFile(request.DiffPath); var finalPaths = GetUniqueReplacementOutputPaths(request.OutputPath, request.DiffPath); var finalOutputPath = finalPaths.OutputPath; var finalDiffPath = finalPaths.DiffPath; var finalBmpPath = finalPaths.BmpPath; var tempOutputPath = CreateTempPath(finalOutputPath); var tempDiffPath = CreateTempPath(finalDiffPath); var tempBmpPath = _diffSerializationService.GetBmpPath(tempDiffPath); var workingDocxPath = CreateWorkingDocxPath("replace"); SafeDelete(tempOutputPath); SafeDelete(tempDiffPath); SafeDelete(tempBmpPath); SafeDelete(workingDocxPath); try { PrepareWorkingDocx(request.SourcePath, workingDocxPath, sourceExtension); var fontValidation = _fontValidationService.EnsureFontsAvailable(workingDocxPath); ReportFontFallback(progress, request.SourcePath, fontValidation); var baselineFieldFindings = ScanBrokenFieldResults(workingDocxPath); var rules = (request.RuleFile == null ? new List() : request.RuleFile.Rules) .Where(rule => rule != null && rule.IsEnabled && !string.IsNullOrEmpty(rule.SearchText) && rule.Target == RuleTarget.DocumentContent) .ToList(); var diff = _diffSerializationService.CreateBaseDocument(); diff.SourceDocument = BuildDocumentInfo(request.SourcePath, request.SourceRootDirectory); diff.RuleFile = BuildRuleFileInfo(request.RuleFilePath, request.RuleFile, rules); diff.Payload = new DiffPayload(); var touchedRoots = new HashSet(); using (var document = OpenEditableWorkingDocument(request.SourcePath, workingDocxPath, "replace")) { cancellationToken.ThrowIfCancellationRequested(); ProcessTextReplacements(document, rules, diff.Payload, touchedRoots, progress, request.SourcePath, cancellationToken); if (request.EnableImageReplacement) { ProcessImageReplacements(document, diff.Payload, request.FixedImageSeed, cancellationToken); } SaveTouchedRoots(touchedRoots, document); } ValidateNoBrokenFieldResults(workingDocxPath, request.SourcePath, baselineFieldFindings, "替换", progress); CommitWorkingDocument(workingDocxPath, tempOutputPath, outputExtension); diff.ReplacedDocument = BuildDocumentInfo(tempOutputPath, Path.GetDirectoryName(finalOutputPath) ?? string.Empty, finalOutputPath); RecordFileNameChangeOperation(diff); diff.Payload.Statistics.TextReplaceCount = diff.Payload.Operations.Count(item => string.Equals(item.Type, "text", StringComparison.Ordinal)); diff.Payload.Statistics.ImageReplaceCount = diff.Payload.Operations.Count(item => string.Equals(item.Type, "image", StringComparison.Ordinal)); _diffSerializationService.Write(diff, tempDiffPath, request.EncryptDiff); File.Move(tempOutputPath, finalOutputPath); File.Move(tempDiffPath, finalDiffPath); File.Move(tempBmpPath, finalBmpPath); return new FileProcessResult { OutputPath = finalOutputPath, DiffPath = finalDiffPath, BmpPath = finalBmpPath, TextOperationCount = diff.Payload.Statistics.TextReplaceCount, ImageOperationCount = diff.Payload.Statistics.ImageReplaceCount, }; } catch { SafeDelete(tempOutputPath); SafeDelete(tempDiffPath); SafeDelete(tempBmpPath); throw; } finally { SafeDelete(workingDocxPath); } } public FileProcessResult Restore(RestoreFileRequest request, IProgress progress, CancellationToken cancellationToken) { if (request == null) { throw new ArgumentNullException("request"); } var replacedExtension = NormalizeWordExtension(request.ReplacedPath); var outputExtension = NormalizeWordExtension(request.OutputPath); var loaded = _diffSerializationService.Load(request.DiffPath); if (loaded.Document == null || loaded.Document.Payload == null) { throw new InvalidDataException("差异文件缺少有效载荷。"); } var currentFingerprint = HashUtility.ComputeFileSha256Base64(request.ReplacedPath); if (!string.Equals(currentFingerprint, loaded.Document.ReplacedDocument.Fingerprint, StringComparison.Ordinal)) { throw new InvalidDataException(BuildReplacedFingerprintMismatchMessage(request.ReplacedPath, loaded.Document, currentFingerprint)); } ValidateRestoreCompatibility(request, loaded.Document); ValidateDiffAlgorithm(loaded.Document); EnsureDirectoryForFile(request.OutputPath); var finalOutputPath = GetUniquePath(request.OutputPath); var tempOutputPath = CreateTempPath(finalOutputPath); var workingDocxPath = CreateWorkingDocxPath("restore"); SafeDelete(tempOutputPath); SafeDelete(workingDocxPath); try { PrepareWorkingDocx(request.ReplacedPath, workingDocxPath, replacedExtension); var fontValidation = _fontValidationService.EnsureFontsAvailable(workingDocxPath); ReportFontFallback(progress, request.ReplacedPath, fontValidation); var baselineFieldFindings = ScanBrokenFieldResults(workingDocxPath); var touchedRoots = new HashSet(); using (var document = OpenEditableWorkingDocument(request.ReplacedPath, workingDocxPath, "restore")) { cancellationToken.ThrowIfCancellationRequested(); RestoreText(document, loaded.Document.Payload, progress, request.ReplacedPath, cancellationToken, touchedRoots); RestoreImages(document, loaded.Document.Payload, cancellationToken); SaveTouchedRoots(touchedRoots, document); } ValidateNoBrokenFieldResults(workingDocxPath, request.ReplacedPath, baselineFieldFindings, "恢复", progress); CommitWorkingDocument(workingDocxPath, tempOutputPath, outputExtension); File.Move(tempOutputPath, finalOutputPath); return new FileProcessResult { OutputPath = finalOutputPath, DiffPath = request.DiffPath, BmpPath = Path.GetExtension(request.DiffPath).Equals(".bmp", StringComparison.OrdinalIgnoreCase) ? request.DiffPath : _diffSerializationService.GetBmpPath(request.DiffPath), TextOperationCount = loaded.Document.Payload.Statistics.TextReplaceCount, ImageOperationCount = loaded.Document.Payload.Statistics.ImageReplaceCount, }; } catch { SafeDelete(tempOutputPath); throw; } finally { SafeDelete(workingDocxPath); } } private void ValidateRestoreCompatibility(RestoreFileRequest request, DiffDocument diff) { if (diff == null) { throw new ArgumentNullException("diff"); } if (diff.App == null || !string.Equals(diff.App.Name, "DCIT", StringComparison.Ordinal) || !string.Equals(diff.App.Version, _diffSerializationService.AppVersion, StringComparison.Ordinal)) { throw new InvalidDataException("差异文件程序版本与当前程序不兼容。"); } if (diff.RuleFile != null && !string.IsNullOrWhiteSpace(diff.RuleFile.Fingerprint) && DiffContainsTextOperations(diff)) { if (string.IsNullOrWhiteSpace(request.CurrentRuleFilePath) || !File.Exists(request.CurrentRuleFilePath)) { throw new InvalidDataException("恢复时必须提供与差异文件匹配的规则文件。"); } var currentRuleFingerprint = HashUtility.ComputeFileSha256Base64(request.CurrentRuleFilePath); if (!string.Equals(currentRuleFingerprint, diff.RuleFile.Fingerprint, StringComparison.Ordinal)) { throw new InvalidDataException("当前规则文件与差异文件记录的规则指纹不匹配。"); } } } private static void RecordFileNameChangeOperation(DiffDocument diff) { if (diff == null || diff.Payload == null) { return; } if (diff.Payload.Operations == null) { diff.Payload.Operations = new List(); } var originalFileName = diff.SourceDocument == null ? null : diff.SourceDocument.FileName; var replacedFileName = diff.ReplacedDocument == null ? null : diff.ReplacedDocument.FileName; if (string.IsNullOrWhiteSpace(originalFileName) || string.IsNullOrWhiteSpace(replacedFileName) || string.Equals(originalFileName, replacedFileName, StringComparison.Ordinal)) { return; } diff.Payload.Operations.Insert(0, new DiffOperation { OperationId = "F000001", Type = "fileName", ObjectType = "documentFileName", Position = new DiffPosition { StoryType = "file", ContainerPath = "fileName", ParagraphIndex = -1, }, OriginalText = originalFileName, WrittenText = replacedFileName, }); } private static bool DiffContainsTextOperations(DiffDocument diff) { return diff != null && diff.Payload != null && diff.Payload.Operations != null && diff.Payload.Operations.Any(item => item != null && string.Equals(item.Type, "text", StringComparison.Ordinal)); } private static string BuildReplacedFingerprintMismatchMessage(string replacedPath, DiffDocument diff, string currentFingerprint) { var replaced = diff == null ? null : diff.ReplacedDocument; return string.Format( CultureInfo.InvariantCulture, "恢复输入文档与差异文件记录的替换后文档指纹不匹配。 当前文件={0}; 差异记录文件名={1}; 差异记录相对路径={2}; 指纹算法={3}; 当前指纹={4}; 期望指纹={5}", replacedPath ?? string.Empty, replaced == null ? string.Empty : replaced.FileName ?? string.Empty, replaced == null ? string.Empty : replaced.RelativePath ?? string.Empty, replaced == null ? "SHA-256" : replaced.FingerprintAlgorithm ?? "SHA-256", currentFingerprint ?? string.Empty, replaced == null ? string.Empty : replaced.Fingerprint ?? string.Empty); } private static void ValidateDiffAlgorithm(DiffDocument diff) { if (diff.Algorithm == null || !string.Equals(diff.Algorithm.DiffVersion, SupportedDiffVersion, StringComparison.Ordinal) || !string.Equals(diff.Algorithm.BmpCodecVersion, SupportedBmpCodecVersion, StringComparison.Ordinal)) { throw new InvalidDataException("差异文件算法版本不受支持。"); } } private void PrepareWorkingDocx(string sourcePath, string workingDocxPath, string sourceExtension) { EnsureDocConversionService(sourceExtension); EnsureDirectoryForFile(workingDocxPath); if (string.Equals(sourceExtension, ".docx", StringComparison.OrdinalIgnoreCase)) { File.Copy(sourcePath, workingDocxPath, true); } else { _docConversionService.Convert(sourcePath, workingDocxPath); } EnsureWritableFile(workingDocxPath); VerifyWorkingDocxIntegrity(workingDocxPath, sourcePath, sourceExtension); } private static void VerifyWorkingDocxIntegrity(string workingDocxPath, string sourcePath, string sourceExtension) { try { using (var document = WordprocessingDocument.Open(workingDocxPath, false)) { if (document.MainDocumentPart == null) { throw new InvalidDataException("工作文档缺少 MainDocumentPart。"); } } } catch (Exception ex) { var fileInfo = new FileInfo(workingDocxPath); throw new InvalidDataException( string.Format( CultureInfo.InvariantCulture, "准备工作文档失败:生成的 .docx 不是有效的 ZIP/OpenXML 文件。源文件={0}; 工作文档={1}; 源扩展名={2}; 工作文件大小={3} 字节; 内部异常={4}: {5}", sourcePath ?? string.Empty, workingDocxPath ?? string.Empty, sourceExtension ?? string.Empty, fileInfo.Exists ? fileInfo.Length : -1, ex == null ? string.Empty : ex.GetType().Name, ex == null ? string.Empty : ex.Message), ex); } } private static WordprocessingDocument OpenEditableWorkingDocument(string sourcePath, string workingDocxPath, string stage) { Exception lastError = null; for (var attempt = 1; attempt <= WorkingDocumentOpenRetryCount; attempt++) { try { EnsureWritableFile(workingDocxPath); return WordprocessingDocument.Open(workingDocxPath, true); } catch (UnauthorizedAccessException ex) { lastError = ex; } catch (IOException ex) { lastError = ex; } if (attempt < WorkingDocumentOpenRetryCount) { Thread.Sleep(WorkingDocumentOpenRetryDelayMilliseconds * attempt); } } throw new IOException(BuildWorkingDocumentOpenFailureMessage(sourcePath, workingDocxPath, stage, lastError), lastError); } private void CommitWorkingDocument(string workingDocxPath, string targetPath, string targetExtension) { EnsureDocConversionService(targetExtension); EnsureDirectoryForFile(targetPath); if (string.Equals(targetExtension, ".docx", StringComparison.OrdinalIgnoreCase)) { File.Copy(workingDocxPath, targetPath, true); return; } _docConversionService.Convert(workingDocxPath, targetPath); } private void EnsureDocConversionService(string extension) { if (string.Equals(extension, ".doc", StringComparison.OrdinalIgnoreCase) && _docConversionService == null) { throw new NotSupportedException("当前配置未提供 .doc 转换能力。"); } } private static void EnsureDirectoryForFile(string path) { var directory = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(directory)) { Directory.CreateDirectory(directory); } } private void UpdateDynamicContent(string workingDocxPath) { if (_documentFieldUpdateService == null || string.IsNullOrWhiteSpace(workingDocxPath)) { return; } _documentFieldUpdateService.UpdateDynamicContent(workingDocxPath); } private static void ReportFontFallback(IProgress progress, string filePath, FontValidationResult result) { if (progress == null || result == null || result.MissingFonts == null || result.MissingFonts.Count == 0) { return; } progress.Report(new FileProcessProgress { FilePath = filePath, FilePercent = 0, Message = "检测到缺失字体: " + string.Join(", ", result.MissingFonts) + "。已使用默认字体继续处理:中文/东亚=宋体,英文和数字=Times New Roman。", IsWarning = true, }); } private static List ScanBrokenFieldResults(string workingDocxPath) { var findings = new List(); if (string.IsNullOrWhiteSpace(workingDocxPath) || !File.Exists(workingDocxPath)) { return findings; } using (var document = WordprocessingDocument.Open(workingDocxPath, false)) { foreach (var context in EnumerateTextParts(document)) { foreach (var target in EnumerateTextTargets(context)) { var state = BuildParagraphState(target); if (string.IsNullOrEmpty(state.Text)) { continue; } foreach (var marker in BrokenFieldResultMarkers) { var searchStart = 0; while (searchStart < state.Text.Length) { var index = state.Text.IndexOf(marker, searchStart, StringComparison.OrdinalIgnoreCase); if (index < 0) { break; } findings.Add(BuildBrokenFieldFinding(target, state.Text, marker, index)); searchStart = index + Math.Max(marker.Length, 1); if (findings.Count >= BrokenFieldResultScanLimit) { break; } } if (findings.Count >= BrokenFieldResultScanLimit) { break; } } if (findings.Count >= BrokenFieldResultScanLimit) { break; } } if (findings.Count >= BrokenFieldResultScanLimit) { break; } } } return findings; } private static BrokenFieldFinding BuildBrokenFieldFinding(ParagraphTarget target, string paragraphText, string marker, int index) { var paragraphElement = target == null ? null : target.ParagraphElement; var hitCell = FindHitTableCell(paragraphElement); TryParseTableLocation(target == null ? null : target.ContainerPath, out var tableIndex, out var rowIndex, out var columnIndex); var fullText = NormalizeWhitespaceForReport(paragraphText ?? string.Empty); var previousText = GetSiblingParagraphText(paragraphElement, true); var nextText = GetSiblingParagraphText(paragraphElement, false); var sameRowCells = GetSameRowCellsSnapshot(paragraphElement, hitCell); var sameColumnCells = GetSameColumnCellsSnapshot(paragraphElement, hitCell); return new BrokenFieldFinding { StoryType = target == null ? string.Empty : (target.StoryType ?? string.Empty), ContainerPath = target == null ? string.Empty : (target.ContainerPath ?? string.Empty), ParagraphIndex = target == null ? 0 : target.ParagraphIndex, Marker = marker, Excerpt = CreateValidationExcerpt(paragraphText, index, marker.Length), FullParagraphText = TruncateForReport(fullText, 500), TableIndex = tableIndex, RowIndex = rowIndex, ColumnIndex = columnIndex, PreviousParagraphText = previousText, NextParagraphText = nextText, SameRowCellsText = sameRowCells, SameColumnCellsText = sameColumnCells, }; } private static void ValidateNoBrokenFieldResults( string workingDocxPath, string sourcePath, IReadOnlyCollection baselineFindings, string operationName, IProgress progress) { if (string.IsNullOrWhiteSpace(workingDocxPath) || !File.Exists(workingDocxPath)) { return; } var currentFindings = ScanBrokenFieldResults(workingDocxPath); if (currentFindings.Count == 0) { return; } var baselineIdentities = new HashSet( (baselineFindings ?? Enumerable.Empty()).Select(item => item.Identity), StringComparer.Ordinal); var newFindings = currentFindings .Where(item => !baselineIdentities.Contains(item.Identity)) .ToList(); var baselineCount = currentFindings.Count - newFindings.Count; if (baselineCount > 0) { progress?.Report(new FileProcessProgress { FilePath = sourcePath ?? string.Empty, FilePercent = 0, Message = string.Format( CultureInfo.InvariantCulture, "检测到源文档已存在 {0} 处交叉引用/域结果错误标记(属基线已存在问题),将继续完成{1}。", baselineCount, operationName ?? string.Empty), IsWarning = true, }); } if (newFindings.Count == 0) { return; } var detailBuilder = new StringBuilder(); for (var i = 0; i < newFindings.Count; i++) { detailBuilder.AppendLine(); detailBuilder.AppendLine("=== 命中 [" + (i + 1).ToString(CultureInfo.InvariantCulture) + "] ==="); AppendFindingDetail(detailBuilder, newFindings[i]); } throw new InvalidDataException( string.Format( CultureInfo.InvariantCulture, "文档验证失败:检测到{0}过程中新增的交叉引用/域结果错误,禁止输出当前{1}结果。源文件={2}; 工作文件={3}; 新增命中数={4}; 基线已命中数={5}; 明细如下:{6}", operationName ?? string.Empty, operationName ?? string.Empty, sourcePath ?? string.Empty, workingDocxPath, newFindings.Count, baselineCount, detailBuilder)); } private static void AppendFindingDetail(StringBuilder sb, BrokenFieldFinding finding) { if (finding == null) { return; } sb.AppendLine(" Story = " + (finding.StoryType ?? string.Empty)); sb.AppendLine(" Container = " + (finding.ContainerPath ?? string.Empty)); if (finding.TableIndex.HasValue && finding.RowIndex.HasValue && finding.ColumnIndex.HasValue) { sb.AppendLine(string.Format( CultureInfo.InvariantCulture, " 表格位置 = 第{0}个表 第{1}行 第{2}列 (基于0的索引)", finding.TableIndex.Value, finding.RowIndex.Value, finding.ColumnIndex.Value)); } sb.AppendLine(" Paragraph = " + finding.ParagraphIndex.ToString(CultureInfo.InvariantCulture)); sb.AppendLine(" Marker = " + (finding.Marker ?? string.Empty)); sb.AppendLine(" Excerpt = " + (finding.Excerpt ?? string.Empty)); sb.AppendLine(" 段落完整文本= " + (finding.FullParagraphText ?? string.Empty)); if (!string.IsNullOrEmpty(finding.PreviousParagraphText)) { sb.AppendLine(" 前一段落 = " + finding.PreviousParagraphText); } if (!string.IsNullOrEmpty(finding.NextParagraphText)) { sb.AppendLine(" 后一段落 = " + finding.NextParagraphText); } if (!string.IsNullOrEmpty(finding.SameRowCellsText)) { sb.AppendLine(" 同行所有单元格 = " + finding.SameRowCellsText); } if (!string.IsNullOrEmpty(finding.SameColumnCellsText)) { sb.AppendLine(" 同列所有单元格 = " + finding.SameColumnCellsText); } } private static string CreateValidationExcerpt(string text, int index, int length) { if (string.IsNullOrEmpty(text)) { return string.Empty; } const int Radius = 80; var start = Math.Max(0, index - Radius); var end = Math.Min(text.Length, index + length + Radius); var excerpt = text.Substring(start, end - start) .Replace('\r', ' ') .Replace('\n', ' ') .Replace('\t', ' '); if (start > 0) { excerpt = "..." + excerpt; } if (end < text.Length) { excerpt += "..."; } return excerpt; } private static string TruncateForReport(string text, int maxLength) { if (string.IsNullOrEmpty(text)) { return string.Empty; } const string ellipsis = "..."; if (text.Length <= maxLength) { return text; } if (maxLength <= ellipsis.Length) { return text.Substring(0, maxLength); } return text.Substring(0, maxLength - ellipsis.Length) + ellipsis; } private static string NormalizeWhitespaceForReport(string text) { if (string.IsNullOrEmpty(text)) { return string.Empty; } var sb = new StringBuilder(text.Length); var lastWasWhitespace = false; foreach (var ch in text) { if (ch == '\r' || ch == '\n' || ch == '\t' || ch == ' ') { if (!lastWasWhitespace) { sb.Append(' '); lastWasWhitespace = true; } continue; } sb.Append(ch); lastWasWhitespace = false; } return sb.ToString().Trim(); } private static string ExtractParagraphVisibleText(OpenXmlElement paragraph) { if (paragraph == null) { return string.Empty; } var sb = new StringBuilder(); foreach (var text in paragraph.Descendants()) { if (text != null) { sb.Append(text.Text); } } return sb.ToString(); } private static void TryParseTableLocation(string containerPath, out int? tableIndex, out int? rowIndex, out int? columnIndex) { tableIndex = null; rowIndex = null; columnIndex = null; if (string.IsNullOrWhiteSpace(containerPath)) { return; } var match = Regex.Match(containerPath, @"tbl\[(\d+)\]/tr\[(\d+)\]/tc\[(\d+)\]", RegexOptions.CultureInvariant); if (!match.Success) { return; } if (int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var t)) { tableIndex = t; } if (int.TryParse(match.Groups[2].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var r)) { rowIndex = r; } if (int.TryParse(match.Groups[3].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var c)) { columnIndex = c; } } private static string GetSiblingParagraphText(OpenXmlElement paragraph, bool previous) { if (paragraph == null) { return null; } var sibling = previous ? paragraph.PreviousSibling() : paragraph.NextSibling(); if (sibling is W.Paragraph wp) { var text = NormalizeWhitespaceForReport(ExtractParagraphVisibleText(wp)); return TruncateForReport(text, 200); } return null; } private static string GetSameRowCellsSnapshot(OpenXmlElement paragraph, OpenXmlElement hitCell) { if (paragraph == null || hitCell == null) { return null; } var row = hitCell.Parent as W.TableRow; if (row == null) { return null; } var sb = new StringBuilder(); var colIdx = 0; foreach (var cell in row.Elements()) { if (sb.Length > 0) { sb.Append(" | "); } var firstPara = cell.Descendants().FirstOrDefault(); var text = firstPara != null ? ExtractParagraphVisibleText(firstPara) : string.Empty; text = NormalizeWhitespaceForReport(text); var marker = ReferenceEquals(cell, hitCell) ? " <==命中此单元格" : string.Empty; sb.AppendFormat( CultureInfo.InvariantCulture, "[列{0}] {1}{2}", colIdx, TruncateForReport(text, 100), marker); colIdx++; } return sb.ToString(); } private static string GetSameColumnCellsSnapshot(OpenXmlElement paragraph, OpenXmlElement hitCell) { if (paragraph == null || hitCell == null) { return null; } var row = hitCell.Parent as W.TableRow; if (row == null) { return null; } var table = row.Parent as W.Table; if (table == null) { return null; } int hitColumnIndex; int hitRowIndex; { var cells = row.Elements().ToList(); hitColumnIndex = cells.TakeWhile(c => !ReferenceEquals(c, hitCell)).Count(); var rows = table.Elements().ToList(); hitRowIndex = rows.TakeWhile(r => !ReferenceEquals(r, row)).Count(); } var sb = new StringBuilder(); var rowIdx = 0; foreach (var currentRow in table.Elements()) { var cells = currentRow.Elements().ToList(); if (hitColumnIndex < cells.Count) { if (sb.Length > 0) { sb.Append(" | "); } var firstPara = cells[hitColumnIndex].Descendants().FirstOrDefault(); var text = firstPara != null ? ExtractParagraphVisibleText(firstPara) : string.Empty; text = NormalizeWhitespaceForReport(text); var marker = rowIdx == hitRowIndex ? " <==命中此行" : string.Empty; sb.AppendFormat( CultureInfo.InvariantCulture, "[行{0}] {1}{2}", rowIdx, TruncateForReport(text, 100), marker); } rowIdx++; } return sb.ToString(); } private static OpenXmlElement FindHitTableCell(OpenXmlElement paragraph) { if (paragraph == null) { return null; } foreach (var ancestor in paragraph.Ancestors()) { if (ancestor is W.TableCell) { return ancestor; } } return null; } private static string NormalizeWordExtension(string path) { var extension = Path.GetExtension(path) ?? string.Empty; if (!extension.Equals(".doc", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".docx", StringComparison.OrdinalIgnoreCase)) { throw new NotSupportedException("当前版本仅支持 .doc 与 .docx 文档。"); } return extension; } private static DiffDocumentInfo BuildDocumentInfo(string filePath, string rootDirectory) { return BuildDocumentInfo(filePath, rootDirectory, filePath); } private static DiffDocumentInfo BuildDocumentInfo(string fingerprintSourcePath, string rootDirectory, string logicalPath) { var metadataPath = string.IsNullOrWhiteSpace(logicalPath) ? fingerprintSourcePath : logicalPath; var relativePath = metadataPath; if (!string.IsNullOrWhiteSpace(rootDirectory) && Directory.Exists(rootDirectory)) { try { relativePath = PathUtility.GetRelativePath(rootDirectory, metadataPath); } catch { relativePath = Path.GetFileName(metadataPath); } } return new DiffDocumentInfo { FileName = Path.GetFileName(metadataPath), RelativePath = relativePath, Extension = Path.GetExtension(metadataPath), FingerprintAlgorithm = "SHA-256", Fingerprint = HashUtility.ComputeFileSha256Base64(fingerprintSourcePath), }; } private static DiffRuleFileInfo BuildRuleFileInfo(string ruleFilePath, RuleFile ruleFile, IList effectiveRules) { var fileName = string.IsNullOrWhiteSpace(ruleFilePath) ? "unsaved.rule" : Path.GetFileName(ruleFilePath); string fingerprint; if (!string.IsNullOrWhiteSpace(ruleFilePath) && File.Exists(ruleFilePath)) { fingerprint = HashUtility.ComputeFileSha256Base64(ruleFilePath); } else if (effectiveRules == null || effectiveRules.Count == 0) { fileName = string.Empty; fingerprint = string.Empty; } else { var json = Newtonsoft.Json.JsonConvert.SerializeObject(ruleFile ?? new RuleFile(), Newtonsoft.Json.Formatting.None); fingerprint = HashUtility.ComputeSha256Base64(json); } return new DiffRuleFileInfo { FileName = fileName, Version = ruleFile == null ? "1.0" : ruleFile.Version ?? "1.0", FingerprintAlgorithm = "SHA-256", Fingerprint = fingerprint, }; } private static string CreateTempPath(string finalPath) { var directory = Path.GetDirectoryName(finalPath) ?? string.Empty; if (!string.IsNullOrWhiteSpace(directory)) { Directory.CreateDirectory(directory); } var name = Path.GetFileNameWithoutExtension(finalPath); var extension = Path.GetExtension(finalPath); return Path.Combine(directory, string.Format("{0}.tmp-{1}{2}", name, Guid.NewGuid().ToString("N"), extension)); } private static string CreateWorkingDocxPath(string prefix) { var directory = Path.Combine(Path.GetTempPath(), "DCIT", "processing", prefix ?? "document"); Directory.CreateDirectory(directory); return Path.Combine(directory, Guid.NewGuid().ToString("N") + ".docx"); } private static void EnsureWritableFile(string path) { if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) { return; } var attributes = File.GetAttributes(path); if ((attributes & FileAttributes.ReadOnly) != 0) { File.SetAttributes(path, attributes & ~FileAttributes.ReadOnly); } } private static string GetUniquePath(string requestedPath) { if (!File.Exists(requestedPath)) { return requestedPath; } var directory = Path.GetDirectoryName(requestedPath) ?? string.Empty; var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(requestedPath); var extension = Path.GetExtension(requestedPath); var index = 1; string candidate; do { candidate = Path.Combine(directory, string.Format("{0}_{1}{2}", fileNameWithoutExtension, index, extension)); index++; } while (File.Exists(candidate)); return candidate; } private ReplacementOutputPaths GetUniqueReplacementOutputPaths(string requestedOutputPath, string requestedDiffPath) { var outputDirectory = Path.GetDirectoryName(requestedOutputPath) ?? string.Empty; var diffDirectory = Path.GetDirectoryName(requestedDiffPath) ?? string.Empty; var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(requestedOutputPath); var diffFileNameWithoutExtension = Path.GetFileNameWithoutExtension(requestedDiffPath); var outputExtension = Path.GetExtension(requestedOutputPath); var diffExtension = Path.GetExtension(requestedDiffPath); var index = 0; while (true) { var suffix = index == 0 ? string.Empty : "_" + index.ToString(CultureInfo.InvariantCulture); var outputPath = Path.Combine(outputDirectory, outputFileNameWithoutExtension + suffix + outputExtension); var diffPath = Path.Combine(diffDirectory, diffFileNameWithoutExtension + suffix + diffExtension); var bmpPath = _diffSerializationService.GetBmpPath(diffPath); if (!File.Exists(outputPath) && !File.Exists(diffPath) && !File.Exists(bmpPath)) { return new ReplacementOutputPaths { OutputPath = outputPath, DiffPath = diffPath, BmpPath = bmpPath, }; } index++; } } private static void SafeDelete(string path) { try { if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) { File.Delete(path); } } catch { } } private static string BuildWorkingDocumentOpenFailureMessage(string sourcePath, string workingDocxPath, string stage, Exception exception) { var builder = new StringBuilder(); builder.Append("无法以可编辑方式打开临时工作副本。"); builder.Append(" 阶段=").Append(stage ?? string.Empty); builder.Append("; 源文件=").Append(sourcePath ?? string.Empty); builder.Append("; 临时工作文件=").Append(workingDocxPath ?? string.Empty); builder.Append("; 尝试次数=").Append(WorkingDocumentOpenRetryCount.ToString(CultureInfo.InvariantCulture)); AppendFileSnapshot(builder, "源文件", sourcePath); AppendFileSnapshot(builder, "临时工作文件", workingDocxPath); if (exception != null) { builder.Append("; 最近异常=").Append(exception.GetType().FullName).Append(": ").Append(exception.Message); } return builder.ToString(); } private static void AppendFileSnapshot(StringBuilder builder, string label, string path) { if (builder == null || string.IsNullOrWhiteSpace(path)) { return; } try { if (!File.Exists(path)) { builder.Append("; ").Append(label).Append("存在=否"); return; } var info = new FileInfo(path); builder.Append("; ").Append(label).Append("存在=是"); builder.Append("; ").Append(label).Append("大小=").Append(info.Length.ToString(CultureInfo.InvariantCulture)); builder.Append("; ").Append(label).Append("属性=").Append(info.Attributes.ToString()); builder.Append("; ").Append(label).Append("只读=").Append(info.IsReadOnly ? "是" : "否"); builder.Append("; ").Append(label).Append("最后写入时间=").Append(info.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture)); builder.Append("; ").Append(label).Append("最后写入时间(UTC)=").Append(info.LastWriteTimeUtc.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture)); builder.Append("; ").Append(label).Append("SHA256=").Append(HashUtility.ComputeFileSha256Base64(path)); } catch (Exception ex) { builder.Append("; ").Append(label).Append("快照失败=").Append(ex.GetType().FullName).Append(": ").Append(ex.Message); } } private static IEnumerable EnumerateTextParts(WordprocessingDocument document) { var main = document.MainDocumentPart; if (main == null) { yield break; } if (main.Document != null) { yield return new PartContext { Part = main, Root = main.Document, PartUri = main.Uri.ToString(), StoryType = "main", }; } var queue = new Queue(); var visited = new HashSet(); queue.Enqueue(main); while (queue.Count > 0) { var container = queue.Dequeue(); foreach (var pair in container.Parts) { var part = pair.OpenXmlPart; if (part == null || !visited.Add(part)) { continue; } if (part.RootElement != null && IsTextRelevantPart(part)) { yield return new PartContext { Part = part, Root = part.RootElement, PartUri = part.Uri.ToString(), StoryType = GetStoryType(part), }; } queue.Enqueue(part); } } } private static IEnumerable EnumerateImageParts(WordprocessingDocument document) { foreach (var partContext in EnumerateTextParts(document)) { var drawings = partContext.Root.Descendants().ToList(); for (var index = 0; index < drawings.Count; index++) { var drawing = drawings[index]; var blip = drawing.Descendants() .FirstOrDefault(item => item.Embed != null && !string.IsNullOrWhiteSpace(item.Embed.Value)); if (blip == null) { continue; } ImagePart imagePart; try { imagePart = partContext.Part.GetPartById(blip.Embed.Value) as ImagePart; } catch { imagePart = null; } if (imagePart == null) { continue; } yield return CreateImageContext(partContext, drawing, blip, imagePart, index); } var vmlContainers = partContext.Root .Descendants() .Where(item => (item is W.Picture picture && !picture.Ancestors().Any()) || item is W.EmbeddedObject) .ToList(); for (var index = 0; index < vmlContainers.Count; index++) { var containerElement = vmlContainers[index]; var imageData = containerElement.Descendants() .FirstOrDefault(item => !string.IsNullOrWhiteSpace(GetVmlRelationshipId(item))); if (imageData == null) { continue; } var relationshipId = GetVmlRelationshipId(imageData); ImagePart imagePart; try { imagePart = partContext.Part.GetPartById(relationshipId) as ImagePart; } catch { imagePart = null; } if (imagePart == null) { continue; } yield return CreateVmlImageContext(partContext, containerElement, imageData, imagePart, relationshipId, index); } } } private static ImageContext CreateImageContext(PartContext partContext, W.Drawing drawing, A.Blip blip, ImagePart imagePart, int drawingIndex) { var inline = drawing.GetFirstChild(); var anchor = drawing.GetFirstChild(); var storyType = drawing.Ancestors().Any(item => string.Equals(item.LocalName, "txbxContent", StringComparison.OrdinalIgnoreCase)) ? "textbox" : partContext.StoryType; long width = 0; long height = 0; if (inline != null && inline.Extent != null) { width = inline.Extent.Cx == null ? 0L : inline.Extent.Cx.Value; height = inline.Extent.Cy == null ? 0L : inline.Extent.Cy.Value; } else if (anchor != null && anchor.Extent != null) { width = anchor.Extent.Cx == null ? 0L : anchor.Extent.Cx.Value; height = anchor.Extent.Cy == null ? 0L : anchor.Extent.Cy.Value; } var transform = drawing.Descendants().FirstOrDefault(); var rotation = transform != null && transform.Rotation != null ? transform.Rotation.Value / 60000d : 0d; return new ImageContext { ParentPart = partContext.Part, ImagePart = imagePart, RelationshipId = blip.Embed.Value, RelationKey = (partContext.PartUri ?? string.Empty) + "::" + blip.Embed.Value, PartUri = imagePart.Uri.ToString(), StoryType = storyType, ContainerPath = BuildContainerPath(partContext.PartUri, drawing, partContext.Root), ContainerKind = "drawing", ObjectType = inline != null ? "inlineImage" : "floatingImage", Layout = new DiffImageLayout { DisplayWidthEmu = width, DisplayHeightEmu = height, WrapMode = DetermineWrapMode(inline, anchor), RotationDegree = rotation, }, ObjectIndex = drawingIndex, }; } private static ImageContext CreateVmlImageContext(PartContext partContext, OpenXmlElement containerElement, V.ImageData imageData, ImagePart imagePart, string relationshipId, int pictureIndex) { var shape = imageData.Ancestors().FirstOrDefault(); var styleMap = ParseVmlStyle(shape == null ? null : shape.Style == null ? null : shape.Style.Value); var storyType = containerElement.Ancestors().Any(item => string.Equals(item.LocalName, "txbxContent", StringComparison.OrdinalIgnoreCase)) ? "textbox" : partContext.StoryType; var embeddedProgramId = GetEmbeddedProgramId(containerElement); var embeddedPackageExtension = GetEmbeddedPackageExtension(partContext.Part, containerElement); return new ImageContext { ParentPart = partContext.Part, ImagePart = imagePart, RelationshipId = relationshipId, RelationKey = (partContext.PartUri ?? string.Empty) + "::" + relationshipId, PartUri = imagePart.Uri.ToString(), StoryType = storyType, ContainerPath = BuildContainerPath(partContext.PartUri, containerElement, partContext.Root), ContainerKind = GetContainerKind(containerElement), EmbeddedProgramId = embeddedProgramId, EmbeddedPackageExtension = embeddedPackageExtension, ObjectType = DetermineVmlObjectType(styleMap, containerElement), Layout = new DiffImageLayout { DisplayWidthEmu = ParseVmlDimensionToEmu(styleMap, "width"), DisplayHeightEmu = ParseVmlDimensionToEmu(styleMap, "height"), WrapMode = DetermineVmlWrapMode(styleMap, containerElement), RotationDegree = ParseVmlRotation(styleMap), }, ObjectIndex = pictureIndex, }; } private static string DetermineWrapMode(DW.Inline inline, DW.Anchor anchor) { if (inline != null) { return "inline"; } if (anchor == null) { return "unknown"; } if (anchor.Elements().Any()) { return "square"; } if (anchor.Elements().Any()) { return "tight"; } if (anchor.Elements().Any()) { return "through"; } if (anchor.Elements().Any()) { return "topBottom"; } if (anchor.Elements().Any()) { return "none"; } return "anchor"; } private static string GetVmlRelationshipId(V.ImageData imageData) { if (imageData == null) { return null; } return imageData.RelationshipId == null || string.IsNullOrWhiteSpace(imageData.RelationshipId.Value) ? imageData.RelId == null ? null : imageData.RelId.Value : imageData.RelationshipId.Value; } private static string GetContainerKind(OpenXmlElement containerElement) { if (containerElement is W.EmbeddedObject) { return "embeddedObject"; } if (containerElement is W.Picture) { return "vmlPicture"; } return "unknown"; } private static string GetEmbeddedProgramId(OpenXmlElement containerElement) { var oleObject = GetEmbeddedOleObjectElement(containerElement); return GetOpenXmlAttributeValue(oleObject, "ProgID"); } private static string GetEmbeddedPackageExtension(OpenXmlPartContainer partContainer, OpenXmlElement containerElement) { var oleObject = GetEmbeddedOleObjectElement(containerElement); var relationshipId = GetOpenXmlAttributeValue(oleObject, "id"); if (string.IsNullOrWhiteSpace(relationshipId) || partContainer == null) { return null; } try { var part = partContainer.GetPartById(relationshipId); return NormalizePackageExtension(part == null ? null : Path.GetExtension(part.Uri.ToString())); } catch { return null; } } private static OpenXmlElement GetEmbeddedOleObjectElement(OpenXmlElement containerElement) { return containerElement == null ? null : containerElement .Descendants() .FirstOrDefault(item => string.Equals(item.LocalName, "OLEObject", StringComparison.OrdinalIgnoreCase)); } private static string GetOpenXmlAttributeValue(OpenXmlElement element, string localName) { if (element == null || string.IsNullOrWhiteSpace(localName)) { return null; } var attributes = element.GetAttributes(); if (attributes == null) { return null; } foreach (var attribute in attributes) { if (string.Equals(attribute.LocalName, localName, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(attribute.Value)) { return attribute.Value; } } return null; } private static string NormalizePackageExtension(string extension) { if (string.IsNullOrWhiteSpace(extension)) { return null; } var normalized = extension.Trim(); if (!normalized.StartsWith(".", StringComparison.Ordinal)) { normalized = "." + normalized; } return normalized.ToLowerInvariant(); } private static IDictionary ParseVmlStyle(string style) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); if (string.IsNullOrWhiteSpace(style)) { return map; } var segments = style.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries); foreach (var segment in segments) { var pair = segment.Split(new[] { ':' }, 2); if (pair.Length != 2) { continue; } var key = (pair[0] ?? string.Empty).Trim(); var value = (pair[1] ?? string.Empty).Trim(); if (key.Length == 0) { continue; } map[key] = value; } return map; } private static long ParseVmlDimensionToEmu(IDictionary styleMap, string key) { if (styleMap == null || !styleMap.TryGetValue(key, out var rawValue) || string.IsNullOrWhiteSpace(rawValue)) { return 0L; } var match = Regex.Match(rawValue.Trim(), @"^(?-?\d+(?:\.\d+)?)(?[a-z%]*)$", RegexOptions.IgnoreCase); if (!match.Success) { return 0L; } if (!double.TryParse(match.Groups["value"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var numericValue)) { return 0L; } var unit = (match.Groups["unit"].Value ?? string.Empty).Trim().ToLowerInvariant(); double inches; switch (unit) { case "": case "px": inches = numericValue / 96d; break; case "pt": inches = numericValue / 72d; break; case "pc": inches = numericValue / 6d; break; case "in": inches = numericValue; break; case "cm": inches = numericValue / 2.54d; break; case "mm": inches = numericValue / 25.4d; break; case "twip": case "dxa": inches = numericValue / 1440d; break; default: return 0L; } return (long)Math.Round(inches * 914400d); } private static double ParseVmlRotation(IDictionary styleMap) { if (styleMap == null || !styleMap.TryGetValue("rotation", out var rawValue) || string.IsNullOrWhiteSpace(rawValue)) { return 0d; } var match = Regex.Match(rawValue.Trim(), @"-?\d+(?:\.\d+)?", RegexOptions.IgnoreCase); if (!match.Success) { return 0d; } return double.TryParse(match.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var numericValue) ? numericValue : 0d; } private static string DetermineVmlWrapMode(IDictionary styleMap, OpenXmlElement containerElement) { var wrapType = GetVmlWordWrapType(containerElement); if (!string.IsNullOrWhiteSpace(wrapType)) { var mapped = NormalizeVmlWrapMode(wrapType); if (!string.IsNullOrWhiteSpace(mapped)) { return mapped; } } var styleWrapMode = NormalizeVmlWrapMode(styleMap != null && styleMap.TryGetValue("mso-wrap-style", out var wrapStyle) ? wrapStyle : null); if (!string.IsNullOrWhiteSpace(styleWrapMode)) { return styleWrapMode; } if (styleMap != null && styleMap.TryGetValue("position", out var position) && !string.IsNullOrWhiteSpace(position)) { if (string.Equals(position.Trim(), "absolute", StringComparison.OrdinalIgnoreCase)) { return "anchor"; } } return "inline"; } private static string NormalizeVmlWrapMode(string wrapValue) { if (!string.IsNullOrWhiteSpace(wrapValue)) { var normalized = wrapValue.Trim().ToLowerInvariant(); switch (normalized) { case "square": return "square"; case "tight": return "tight"; case "through": return "through"; case "topandbottom": case "top-bottom": case "topbottom": return "topBottom"; case "none": return "none"; case "inline": return "inline"; case "anchor": return "anchor"; } } return null; } private static string GetVmlWordWrapType(OpenXmlElement containerElement) { if (containerElement == null) { return null; } foreach (var descendant in containerElement.Descendants()) { if (!string.Equals(descendant.LocalName, "wrap", StringComparison.OrdinalIgnoreCase)) { continue; } if (!string.Equals(descendant.NamespaceUri ?? string.Empty, "urn:schemas-microsoft-com:office:word", StringComparison.OrdinalIgnoreCase)) { continue; } var attributes = descendant.GetAttributes(); if (attributes == null) { continue; } var typeAttribute = attributes.FirstOrDefault(item => string.Equals(item.LocalName, "type", StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(typeAttribute.Value)) { return typeAttribute.Value; } } return null; } private static string DetermineVmlObjectType(IDictionary styleMap, OpenXmlElement containerElement) { var wrapMode = DetermineVmlWrapMode(styleMap, containerElement); return string.Equals(wrapMode, "inline", StringComparison.OrdinalIgnoreCase) ? "inlineImage" : "floatingImage"; } private static bool IsTextRelevantPart(OpenXmlPart part) { return part is HeaderPart || part is FooterPart || part is WordprocessingCommentsPart || part is FootnotesPart || part is EndnotesPart || part.GetType().Name.IndexOf("ChartPart", StringComparison.OrdinalIgnoreCase) >= 0; } private static string GetStoryType(OpenXmlPart part) { if (part is HeaderPart) { return "header"; } if (part is FooterPart) { return "footer"; } if (part is WordprocessingCommentsPart) { return "comment"; } if (part is FootnotesPart) { return "footnote"; } if (part is EndnotesPart) { return "endnote"; } if (part.GetType().Name.IndexOf("ChartPart", StringComparison.OrdinalIgnoreCase) >= 0) { return "chart"; } return "main"; } private static void ProcessTextReplacements( WordprocessingDocument document, IList rules, DiffPayload payload, ISet touchedRoots, IProgress progress, string filePath, CancellationToken cancellationToken) { var operationIndex = 1; foreach (var partContext in EnumerateTextParts(document)) { var textTargets = EnumerateTextTargets(partContext).ToList(); for (var paragraphListIndex = 0; paragraphListIndex < textTargets.Count; paragraphListIndex++) { cancellationToken.ThrowIfCancellationRequested(); var target = textTargets[paragraphListIndex]; var paragraphTouched = false; for (var ruleIndex = 0; ruleIndex < rules.Count; ruleIndex++) { var rule = rules[ruleIndex]; progress?.Report(new FileProcessProgress { FilePath = filePath, FilePercent = rules.Count == 0 ? 100 : Math.Min(99, (ruleIndex * 100) / rules.Count), CurrentRuleId = rule.Id, CurrentRuleName = rule.Name, CurrentFileTextCount = payload.Operations.Count(item => string.Equals(item.Type, "text", StringComparison.Ordinal)), TotalTextCount = payload.Operations.Count(item => string.Equals(item.Type, "text", StringComparison.Ordinal)), Message = "正在替换规则 " + (rule.Name ?? rule.Id ?? "-"), }); var state = BuildParagraphState(target); if (string.IsNullOrEmpty(state.Text)) { continue; } var matches = FindMatches(state, rule); if (matches.Count == 0) { continue; } for (var matchIndex = matches.Count - 1; matchIndex >= 0; matchIndex--) { var match = matches[matchIndex]; var position = BuildTextPosition(state, match); var originalWidth = DisplayWidthUtility.Measure(match.OriginalText); var truncatedText = DisplayWidthUtility.TruncateToWidth(match.ReplacementTemplateResult, originalWidth, out var truncated); var writtenText = DisplayWidthUtility.PadToWidth(truncatedText, originalWidth, out var padded); RewriteRange(state, match.StartIndex, match.Length, writtenText, updateFont: true); var currentState = BuildParagraphState(target); position.ParagraphTextLength = currentState.Text == null ? 0 : currentState.Text.Length; position.ParagraphTextHash = BuildParagraphContentHash(currentState.Text); payload.Operations.Add(new DiffOperation { OperationId = "T" + operationIndex.ToString("000000"), Type = "text", ObjectType = DetermineTextObjectType(target, state, match, position), Position = position, Rule = new DiffRuleHitInfo { Id = rule.Id, Name = rule.Name, MatchMode = rule.MatchMode == RuleMatchMode.RegularExpression ? "regex" : "plain", Pattern = rule.SearchText, HitIndex = matches.Count - matchIndex, }, OriginalText = match.OriginalText, ReplacementTemplateResult = match.ReplacementTemplateResult, WrittenText = writtenText, DisplayWidthOriginal = originalWidth, DisplayWidthWritten = DisplayWidthUtility.Measure(writtenText), Truncated = truncated, PaddingApplied = padded, }); operationIndex++; paragraphTouched = true; } } if (paragraphTouched) { touchedRoots.Add(partContext.Root); } } } progress?.Report(new FileProcessProgress { FilePath = filePath, FilePercent = 100, TotalTextCount = payload.Operations.Count(item => string.Equals(item.Type, "text", StringComparison.Ordinal)), CurrentFileTextCount = payload.Operations.Count(item => string.Equals(item.Type, "text", StringComparison.Ordinal)), Message = "文本替换完成", }); } private static void RestoreText( WordprocessingDocument document, DiffPayload payload, IProgress progress, string filePath, CancellationToken cancellationToken, ISet touchedRoots) { var textTargets = EnumerateTextParts(document) .SelectMany(EnumerateTextTargets) .ToList(); var paragraphLookup = textTargets.ToDictionary( target => BuildParagraphLookupKey(target.StoryType, target.ContainerPath, target.ParagraphIndex), target => target, StringComparer.OrdinalIgnoreCase); var paragraphScopeLookup = textTargets .GroupBy(target => BuildParagraphScopeKey(target.StoryType, target.ContainerPath), StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase); var operations = payload.Operations .Where(item => string.Equals(item.Type, "text", StringComparison.Ordinal)) .Reverse() .ToList(); for (var index = 0; index < operations.Count; index++) { cancellationToken.ThrowIfCancellationRequested(); var operation = operations[index]; var key = BuildParagraphLookupKey(operation.Position.StoryType, operation.Position.ContainerPath, operation.Position.ParagraphIndex); var scopeKey = BuildParagraphScopeKey(operation.Position.StoryType, operation.Position.ContainerPath); ParagraphTarget target; ParagraphState state; TextRange range = null; string actualWritten = null; var fallbackState = default(ParagraphState); if (paragraphLookup.TryGetValue(key, out var exactTarget)) { fallbackState = BuildParagraphState(exactTarget); if (TryResolveOperationInParagraph(fallbackState, operation, out range, out actualWritten) && ShouldTrustExactRestoreCandidate(operation, fallbackState, range)) { target = exactTarget; state = fallbackState; } else { target = null; state = null; } } else { target = null; state = null; } if (target == null) { if (!TryResolveRestoreTargetFallback(paragraphScopeLookup, scopeKey, operation, out target, out state, out range, out actualWritten, out var ambiguous)) { if (ambiguous) { throw new InvalidDataException("恢复失败:同一容器内定位到多个候选段落。 " + BuildRestoreOperationContext(operation, key, fallbackState)); } if (fallbackState == null) { throw new InvalidDataException("恢复失败:未找到匹配的文本位置。 " + BuildRestoreOperationContext(operation, key, null)); } throw new InvalidDataException( "恢复失败:当前位置文本与记录不匹配,且未找到可回退段落。 " + BuildRestoreOperationContext(operation, key, fallbackState) + "; 记录写入文本=" + (operation.WrittenText ?? string.Empty) + "; 当前文本=" + ExtractTextRange(fallbackState, TryCreateDiagnosticRange(fallbackState, operation))); } } RewriteRange(state, range.StartIndex, range.Length, operation.OriginalText ?? string.Empty); touchedRoots.Add(target.PartContext.Root); progress?.Report(new FileProcessProgress { FilePath = filePath, FilePercent = operations.Count == 0 ? 100 : Math.Min(100, ((index + 1) * 100) / operations.Count), CurrentRuleId = operation.Rule == null ? null : operation.Rule.Id, CurrentRuleName = operation.Rule == null ? null : operation.Rule.Name, CurrentFileTextCount = index + 1, TotalTextCount = operations.Count, Message = "正在恢复文本操作 " + operation.OperationId, }); } } private static void ProcessImageReplacements(WordprocessingDocument document, DiffPayload payload, int? fixedImageSeed, CancellationToken cancellationToken) { var operationIndex = payload.Operations.Count(item => string.Equals(item.Type, "image", StringComparison.Ordinal)) + 1; var processedByRelation = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var image in EnumerateImageParts(document)) { cancellationToken.ThrowIfCancellationRequested(); var extension = (Path.GetExtension(image.PartUri) ?? string.Empty).ToLowerInvariant(); var isRaster = SupportedRasterExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); var isNonBitmap = SupportedNonBitmapExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); if (!isRaster && !isNonBitmap) { continue; } if (!processedByRelation.TryGetValue(image.RelationKey, out var processed)) { processed = isRaster ? ProcessBitmapImage(image, extension, fixedImageSeed) : ProcessNonBitmapImage(image, extension, fixedImageSeed); processedByRelation[image.RelationKey] = processed; } payload.Operations.Add(new DiffOperation { OperationId = "I" + operationIndex.ToString("000000"), Type = "image", ObjectType = image.ObjectType, ContainerKind = image.ContainerKind, EmbeddedProgramId = image.EmbeddedProgramId, EmbeddedPackageExtension = image.EmbeddedPackageExtension, Position = new DiffPosition { StoryType = image.StoryType, ContainerPath = image.ContainerPath, ParagraphIndex = 0, StartRunIndex = 0, StartCharOffset = 0, EndRunIndex = 0, EndCharOffset = 0, ObjectIndex = image.ObjectIndex, }, SourceKind = processed.SourceKind, OriginalImage = processed.OriginalImage, ConvertedBitmapImage = processed.ConvertedBitmapImage, NewImage = processed.NewImage, Seed = processed.Seed, NoiseSummary = new DiffNoiseSummary { ModifiedPixelRatio = processed.ModifiedPixelRatio, }, Layout = image.Layout, }); operationIndex++; } } private static ProcessedImageResult ProcessBitmapImage(ImageContext image, string extension, int? fixedImageSeed) { var originalBytes = ReadImageBytes(image.ImagePart); var stableKey = image.ContainerPath ?? image.PartUri ?? image.RelationKey; var seed = fixedImageSeed.HasValue ? DeriveFixedSeed(fixedImageSeed.Value, stableKey) : CreateRandomSeed(); DiffImageData originalImage; DiffImageData newImage; double ratio; using (var originalStream = new MemoryStream(originalBytes)) using (var loadedImage = Image.FromStream(originalStream, true, true)) using (var bitmap = new Bitmap(loadedImage)) { ratio = ApplyNoise(bitmap, seed); newImage = CreateBitmapImageData(bitmap, extension); originalImage = CreateDiffImageData(extension, bitmap.Width, bitmap.Height, originalBytes); } WriteImageData(image, Convert.FromBase64String(newImage.Data), newImage.Format); return new ProcessedImageResult { SourceKind = "bitmap", OriginalImage = originalImage, NewImage = newImage, ModifiedPixelRatio = ratio, Seed = seed, }; } private static ProcessedImageResult ProcessNonBitmapImage(ImageContext image, string extension, int? fixedImageSeed) { var originalBytes = ReadImageBytes(image.ImagePart); var stableKey = image.ContainerPath ?? image.PartUri ?? image.RelationKey; var seed = fixedImageSeed.HasValue ? DeriveFixedSeed(fixedImageSeed.Value, stableKey) : CreateRandomSeed(); var bitmapFormat = GetNonBitmapTargetFormat(image); var convertedBitmapImage = RasterizeNonBitmapImage(image, extension, originalBytes, bitmapFormat); DiffImageData newImage; double ratio; using (var convertedStream = new MemoryStream(Convert.FromBase64String(convertedBitmapImage.Data))) using (var loadedImage = Image.FromStream(convertedStream, true, true)) using (var bitmap = new Bitmap(loadedImage)) { ratio = ApplyNoise(bitmap, seed); newImage = CreateBitmapImageData(bitmap, bitmapFormat); } WriteImageData(image, Convert.FromBase64String(newImage.Data), newImage.Format); return new ProcessedImageResult { SourceKind = "nonBitmap", OriginalImage = CreateDiffImageData(extension, convertedBitmapImage.WidthPx, convertedBitmapImage.HeightPx, originalBytes), ConvertedBitmapImage = convertedBitmapImage, NewImage = newImage, ModifiedPixelRatio = ratio, Seed = seed, }; } private static void RestoreImages(WordprocessingDocument document, DiffPayload payload, CancellationToken cancellationToken) { var images = EnumerateImageParts(document).ToList(); var imageBytesCache = new Dictionary(StringComparer.OrdinalIgnoreCase); var processedRelations = new HashSet(StringComparer.OrdinalIgnoreCase); var operations = payload.Operations .Where(item => string.Equals(item.Type, "image", StringComparison.Ordinal)) .Reverse() .ToList(); foreach (var operation in operations) { cancellationToken.ThrowIfCancellationRequested(); if (operation.Position == null || string.IsNullOrWhiteSpace(operation.Position.ContainerPath)) { throw new InvalidDataException("图像恢复缺少位置。"); } var resolution = ResolveImageRestoreTarget(images, operation, imageBytesCache, processedRelations); if (resolution == null || resolution.Image == null) { throw new InvalidDataException("未找到可恢复的图像容器。 " + BuildImageRestoreContext(operation, null, images.Count)); } var image = resolution.Image; if (processedRelations.Contains(image.RelationKey)) { continue; } if (operation.Layout != null && !resolution.LayoutMatches) { throw new InvalidDataException("图像恢复失败:当前图像布局与记录不匹配。 " + BuildImageRestoreContext(operation, resolution, images.Count)); } if (operation.NewImage != null && !resolution.SkipContentVerification) { var actual = GetImageDataBase64(image, imageBytesCache); if (!string.Equals(actual, operation.NewImage.Data, StringComparison.Ordinal)) { throw new InvalidDataException("图像恢复失败:当前图像内容与记录不匹配。 " + BuildImageRestoreContext(operation, resolution, images.Count)); } } var restoreImage = string.Equals(operation.SourceKind, "nonBitmap", StringComparison.OrdinalIgnoreCase) ? operation.ConvertedBitmapImage : operation.OriginalImage; if (restoreImage == null || string.IsNullOrWhiteSpace(restoreImage.Data)) { throw new InvalidDataException("图像恢复缺少原始图像数据。"); } WriteImageData(image, Convert.FromBase64String(restoreImage.Data), restoreImage.Format); processedRelations.Add(image.RelationKey); } } private static ImageRestoreCandidate ResolveImageRestoreTarget( IList images, DiffOperation operation, IDictionary imageBytesCache, ISet processedRelations) { if (images == null || images.Count == 0 || operation == null || operation.Position == null) { return null; } var exactPathCandidate = images .Where(item => string.Equals(item.ContainerPath, operation.Position.ContainerPath, StringComparison.OrdinalIgnoreCase)) .Select(item => BuildImageRestoreCandidate(item, operation, imageBytesCache, processedRelations)) .OrderBy(item => item.IsProcessed ? 1 : 0) .ThenByDescending(item => item.ContentMatches) .ThenByDescending(item => item.ContainerKindMatches) .ThenByDescending(item => item.EmbeddedProgramMatches) .ThenByDescending(item => item.EmbeddedPackageMatches) .ThenByDescending(item => item.ObjectTypeMatches) .ThenByDescending(item => item.LayoutMatches) .FirstOrDefault(); if (exactPathCandidate != null && exactPathCandidate.ContentMatches) { return exactPathCandidate; } if (CanUseStructuralImageRestoreFallback(exactPathCandidate, operation, exactPath: true)) { exactPathCandidate.SkipContentVerification = true; return exactPathCandidate; } var candidates = images .Select(item => BuildImageRestoreCandidate(item, operation, imageBytesCache, processedRelations)) .Where(item => item.StoryMatches && item.ObjectTypeCompatible && item.LayoutMatches && item.ContentMatches && item.ContainerKindMatches && item.EmbeddedProgramMatches && item.EmbeddedPackageMatches) .OrderBy(item => item.IsProcessed ? 1 : 0) .ThenByDescending(item => item.ContainerKindMatches) .ThenByDescending(item => item.EmbeddedProgramMatches) .ThenByDescending(item => item.EmbeddedPackageMatches) .ThenByDescending(item => item.ObjectTypeMatches) .ThenByDescending(item => item.PathSimilarity) .ThenBy(item => item.ObjectIndexDistance) .ThenBy(item => item.Image.ContainerPath ?? string.Empty, StringComparer.OrdinalIgnoreCase) .ToList(); if (candidates.Count == 0) { return ResolveStructuralImageRestoreTarget(images, operation, imageBytesCache, processedRelations) ?? exactPathCandidate; } var top = candidates[0]; var ambiguous = candidates .Where(item => item.IsProcessed == top.IsProcessed && item.PathSimilarity == top.PathSimilarity && item.ObjectIndexDistance == top.ObjectIndexDistance && !string.Equals(item.Image.RelationKey, top.Image.RelationKey, StringComparison.OrdinalIgnoreCase)) .FirstOrDefault(); if (ambiguous != null) { throw new InvalidDataException("图像恢复失败:存在多个等价候选图像,无法唯一定位。 " + BuildImageRestoreContext(operation, top, images.Count)); } return top; } private static ImageRestoreCandidate ResolveStructuralImageRestoreTarget( IList images, DiffOperation operation, IDictionary imageBytesCache, ISet processedRelations) { var candidates = images .Select(item => BuildImageRestoreCandidate(item, operation, imageBytesCache, processedRelations)) .Where(item => CanUseStructuralImageRestoreFallback(item, operation, exactPath: false)) .OrderBy(item => item.IsProcessed ? 1 : 0) .ThenByDescending(item => item.ObjectTypeMatches) .ThenByDescending(item => item.PathSimilarity) .ThenBy(item => item.ObjectIndexDistance) .ThenBy(item => item.Image.ContainerPath ?? string.Empty, StringComparer.OrdinalIgnoreCase) .ToList(); if (candidates.Count == 0) { return null; } var top = candidates[0]; var ambiguous = candidates .Where(item => item.IsProcessed == top.IsProcessed && item.ObjectTypeMatches == top.ObjectTypeMatches && item.PathSimilarity == top.PathSimilarity && item.ObjectIndexDistance == top.ObjectIndexDistance && !string.Equals(item.Image.RelationKey, top.Image.RelationKey, StringComparison.OrdinalIgnoreCase)) .FirstOrDefault(); if (ambiguous != null) { throw new InvalidDataException("图像恢复失败:存在多个结构等价候选图像,无法唯一定位。 " + BuildImageRestoreContext(operation, top, images.Count)); } top.SkipContentVerification = true; return top; } private static bool CanUseStructuralImageRestoreFallback(ImageRestoreCandidate candidate, DiffOperation operation, bool exactPath) { if (candidate == null || candidate.Image == null || operation == null) { return false; } if (!IsVmlRestoreCandidate(candidate, operation)) { return false; } if (!candidate.StoryMatches || !candidate.ContainerKindMatches || !candidate.EmbeddedProgramMatches || !candidate.EmbeddedPackageMatches || !candidate.ObjectTypeCompatible || !candidate.LayoutMatches) { return false; } return exactPath || candidate.ObjectIndexDistance <= 2 || candidate.PathSimilarity >= 400; } private static bool IsVmlRestoreCandidate(ImageRestoreCandidate candidate, DiffOperation operation) { var expectedPath = operation == null || operation.Position == null ? null : operation.Position.ContainerPath; return IsVmlImageContainerKind(candidate.Image.ContainerKind) || IsVmlImageContainerKind(operation == null ? null : operation.ContainerKind) || IsVmlLikeContainerPath(candidate.Image.ContainerPath) || IsVmlLikeContainerPath(expectedPath); } private static bool IsVmlImageContainerKind(string containerKind) { return string.Equals(containerKind, "vmlPicture", StringComparison.OrdinalIgnoreCase) || string.Equals(containerKind, "embeddedObject", StringComparison.OrdinalIgnoreCase); } private static ImageRestoreCandidate BuildImageRestoreCandidate( ImageContext image, DiffOperation operation, IDictionary imageBytesCache, ISet processedRelations) { var expectedStoryType = operation.Position == null ? null : operation.Position.StoryType; var expectedObjectType = operation.ObjectType; var expectedLayout = operation.Layout; var expectedImageData = operation.NewImage == null ? null : operation.NewImage.Data; var expectedPath = operation.Position == null ? null : operation.Position.ContainerPath; var expectedObjectIndex = operation.Position == null ? null : operation.Position.ObjectIndex; var expectedContainerKind = operation.ContainerKind; var expectedEmbeddedProgramId = operation.EmbeddedProgramId; var expectedEmbeddedPackageExtension = NormalizePackageExtension(operation.EmbeddedPackageExtension); var objectTypeMatches = string.IsNullOrWhiteSpace(expectedObjectType) || string.Equals(image.ObjectType ?? string.Empty, expectedObjectType ?? string.Empty, StringComparison.OrdinalIgnoreCase); var objectTypeCompatible = objectTypeMatches || IsCompatibleImageObjectType(expectedObjectType, image.ObjectType, expectedPath, image.ContainerPath); var allowCompatibleLayout = (objectTypeCompatible && !objectTypeMatches) || IsVmlImageContainerKind(expectedContainerKind) || IsVmlImageContainerKind(image.ContainerKind) || IsVmlLikeContainerPath(expectedPath) || IsVmlLikeContainerPath(image.ContainerPath); var containerKindMatches = string.IsNullOrWhiteSpace(expectedContainerKind) || string.Equals(image.ContainerKind ?? string.Empty, expectedContainerKind ?? string.Empty, StringComparison.OrdinalIgnoreCase); var embeddedProgramMatches = string.IsNullOrWhiteSpace(expectedEmbeddedProgramId) || string.Equals(image.EmbeddedProgramId ?? string.Empty, expectedEmbeddedProgramId ?? string.Empty, StringComparison.OrdinalIgnoreCase); var embeddedPackageMatches = string.IsNullOrWhiteSpace(expectedEmbeddedPackageExtension) || string.Equals( NormalizePackageExtension(image.EmbeddedPackageExtension) ?? string.Empty, expectedEmbeddedPackageExtension ?? string.Empty, StringComparison.OrdinalIgnoreCase); return new ImageRestoreCandidate { Image = image, IsProcessed = processedRelations != null && processedRelations.Contains(image.RelationKey), StoryMatches = string.IsNullOrWhiteSpace(expectedStoryType) || string.Equals(image.StoryType ?? string.Empty, expectedStoryType ?? string.Empty, StringComparison.OrdinalIgnoreCase), ContainerKindMatches = containerKindMatches, EmbeddedProgramMatches = embeddedProgramMatches, EmbeddedPackageMatches = embeddedPackageMatches, ObjectTypeMatches = objectTypeMatches, ObjectTypeCompatible = objectTypeCompatible, LayoutMatches = expectedLayout == null || IsLayoutEquivalent(image.Layout, expectedLayout, allowCompatibleLayout), ContentMatches = string.IsNullOrWhiteSpace(expectedImageData) || string.Equals(GetImageDataBase64(image, imageBytesCache), expectedImageData, StringComparison.Ordinal), PathSimilarity = ComputeContainerPathSimilarity(image.ContainerPath, expectedPath), ObjectIndexDistance = ComputeObjectIndexDistance(image.ObjectIndex, expectedObjectIndex), }; } private static string GetImageDataBase64(ImageContext image, IDictionary imageBytesCache) { if (image == null) { return string.Empty; } var cacheKey = image.RelationKey ?? image.ContainerPath ?? image.PartUri ?? Guid.NewGuid().ToString("N"); if (!imageBytesCache.TryGetValue(cacheKey, out var data)) { data = Convert.ToBase64String(ReadImageBytes(image.ImagePart)); imageBytesCache[cacheKey] = data; } return data; } private static int ComputeContainerPathSimilarity(string actualPath, string expectedPath) { if (string.IsNullOrWhiteSpace(actualPath) || string.IsNullOrWhiteSpace(expectedPath)) { return 0; } if (string.Equals(actualPath, expectedPath, StringComparison.OrdinalIgnoreCase)) { return int.MaxValue; } var actualSegments = actualPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); var expectedSegments = expectedPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); var length = Math.Min(actualSegments.Length, expectedSegments.Length); var score = 0; for (var index = 0; index < length; index++) { if (!string.Equals(actualSegments[index], expectedSegments[index], StringComparison.OrdinalIgnoreCase)) { break; } score += 100; } return score; } private static int ComputeObjectIndexDistance(int actualIndex, int? expectedIndex) { if (!expectedIndex.HasValue) { return 0; } return Math.Abs(actualIndex - expectedIndex.Value); } private static string BuildImageRestoreContext(DiffOperation operation, ImageRestoreCandidate candidate, int imageCount) { var builder = new StringBuilder(); builder.Append("OperationId=").Append(operation == null ? string.Empty : operation.OperationId ?? string.Empty); builder.Append("; ObjectType=").Append(operation == null ? string.Empty : operation.ObjectType ?? string.Empty); builder.Append("; ContainerKind=").Append(operation == null ? string.Empty : operation.ContainerKind ?? string.Empty); builder.Append("; EmbeddedProgramId=").Append(operation == null ? string.Empty : operation.EmbeddedProgramId ?? string.Empty); builder.Append("; EmbeddedPackageExtension=").Append(operation == null ? string.Empty : operation.EmbeddedPackageExtension ?? string.Empty); builder.Append("; StoryType=").Append(operation == null || operation.Position == null ? string.Empty : operation.Position.StoryType ?? string.Empty); builder.Append("; Position=").Append(BuildDiffPositionSummary(operation == null ? null : operation.Position)); builder.Append("; TotalImages=").Append(imageCount.ToString(CultureInfo.InvariantCulture)); if (candidate != null && candidate.Image != null) { builder.Append("; CandidatePath=").Append(candidate.Image.ContainerPath ?? string.Empty); builder.Append("; CandidateRelation=").Append(candidate.Image.RelationKey ?? string.Empty); builder.Append("; CandidateContainerKind=").Append(candidate.Image.ContainerKind ?? string.Empty); builder.Append("; CandidateEmbeddedProgramId=").Append(candidate.Image.EmbeddedProgramId ?? string.Empty); builder.Append("; CandidateEmbeddedPackageExtension=").Append(candidate.Image.EmbeddedPackageExtension ?? string.Empty); builder.Append("; CandidateObjectType=").Append(candidate.Image.ObjectType ?? string.Empty); builder.Append("; CandidateWrapMode=").Append(candidate.Image.Layout == null ? string.Empty : candidate.Image.Layout.WrapMode ?? string.Empty); builder.Append("; CandidateObjectIndex=").Append(candidate.Image.ObjectIndex.ToString(CultureInfo.InvariantCulture)); builder.Append("; CandidateStoryMatch=").Append(candidate.StoryMatches ? "Y" : "N"); builder.Append("; CandidateContainerKindMatch=").Append(candidate.ContainerKindMatches ? "Y" : "N"); builder.Append("; CandidateEmbeddedProgramMatch=").Append(candidate.EmbeddedProgramMatches ? "Y" : "N"); builder.Append("; CandidateEmbeddedPackageMatch=").Append(candidate.EmbeddedPackageMatches ? "Y" : "N"); builder.Append("; CandidateObjectTypeMatch=").Append(candidate.ObjectTypeMatches ? "Y" : "N"); builder.Append("; CandidateObjectTypeCompatible=").Append(candidate.ObjectTypeCompatible ? "Y" : "N"); builder.Append("; CandidateLayoutMatch=").Append(candidate.LayoutMatches ? "Y" : "N"); builder.Append("; CandidateContentMatch=").Append(candidate.ContentMatches ? "Y" : "N"); builder.Append("; CandidatePathSimilarity=").Append(candidate.PathSimilarity == int.MaxValue ? "Exact" : candidate.PathSimilarity.ToString(CultureInfo.InvariantCulture)); builder.Append("; CandidateObjectIndexDistance=").Append(candidate.ObjectIndexDistance.ToString(CultureInfo.InvariantCulture)); } return builder.ToString(); } private static string BuildDiffPositionSummary(DiffPosition position) { if (position == null) { return ""; } return string.Format( CultureInfo.InvariantCulture, "story={0},container={1},paragraph={2},objectIndex={3},textStart={4},textEnd={5},runStart={6}:{7},runEnd={8}:{9}", position.StoryType ?? string.Empty, position.ContainerPath ?? string.Empty, position.ParagraphIndex, position.ObjectIndex.HasValue ? position.ObjectIndex.Value.ToString(CultureInfo.InvariantCulture) : "", position.StartTextOffset, position.EndTextOffset, position.StartRunIndex, position.StartCharOffset, position.EndRunIndex, position.EndCharOffset); } private static byte[] ReadImageBytes(ImagePart imagePart) { using (var stream = imagePart.GetStream(FileMode.Open, FileAccess.Read)) using (var memory = new MemoryStream()) { stream.CopyTo(memory); return memory.ToArray(); } } private static string GetNonBitmapTargetFormat(ImageContext image) { return IsVisioEmbeddedObject(image) ? ".bmp" : ".png"; } private static bool IsVisioEmbeddedObject(ImageContext image) { if (image == null || !string.Equals(image.ContainerKind, "embeddedObject", StringComparison.OrdinalIgnoreCase)) { return false; } if (IsVisioProgramId(image.EmbeddedProgramId)) { return true; } var packageExtension = NormalizePackageExtension(image.EmbeddedPackageExtension); return !string.IsNullOrWhiteSpace(packageExtension) && SupportedVisioPackageExtensions.Contains(packageExtension, StringComparer.OrdinalIgnoreCase); } private static bool IsVisioProgramId(string programId) { return !string.IsNullOrWhiteSpace(programId) && programId.Trim().StartsWith("Visio.", StringComparison.OrdinalIgnoreCase); } private static DiffImageData RasterizeNonBitmapImage(ImageContext image, string extension, byte[] originalBytes, string targetBitmapFormat) { var backgroundColor = GetNonBitmapPreviewBackground(image, extension, targetBitmapFormat); using (var bitmap = CreateNonBitmapPreview(image, extension, originalBytes, backgroundColor)) { return CreateBitmapImageData(bitmap, targetBitmapFormat); } } private static Color GetNonBitmapPreviewBackground(ImageContext image, string extension, string targetBitmapFormat) { if (IsVisioEmbeddedObject(image) || IsMetafileExtension(extension) || string.Equals(NormalizeImageFormat(targetBitmapFormat), "bmp", StringComparison.OrdinalIgnoreCase)) { return Color.White; } return Color.Transparent; } private static bool IsMetafileExtension(string extension) { return string.Equals(extension, ".wmf", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".emf", StringComparison.OrdinalIgnoreCase); } private static Bitmap CreateNonBitmapPreview(ImageContext image, string extension, byte[] originalBytes, Color backgroundColor) { if (string.Equals(extension, ".svg", StringComparison.OrdinalIgnoreCase)) { using (var stream = new MemoryStream(originalBytes)) { var svgDocument = SvgDocument.Open(stream); if (svgDocument == null) { throw new InvalidDataException("SVG 图像无法解析。"); } using (var rendered = svgDocument.Draw()) { if (rendered == null) { throw new InvalidDataException("SVG 图像无法渲染。"); } return CreateLayoutBitmap(rendered, image.Layout, backgroundColor); } } } using (var stream = new MemoryStream(originalBytes)) using (var loadedImage = Image.FromStream(stream, true, true)) { return CreateLayoutBitmap(loadedImage, image.Layout, backgroundColor); } } private static Bitmap CreateLayoutBitmap(Image image, DiffImageLayout layout, Color backgroundColor) { var width = ResolvePixelLength(layout == null ? 0L : layout.DisplayWidthEmu, image == null ? 0 : image.Width); var height = ResolvePixelLength(layout == null ? 0L : layout.DisplayHeightEmu, image == null ? 0 : image.Height); var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb); using (var graphics = Graphics.FromImage(bitmap)) { graphics.Clear(backgroundColor); graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; graphics.SmoothingMode = SmoothingMode.HighQuality; graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; graphics.DrawImage(image, new Rectangle(0, 0, width, height)); } return bitmap; } private static int ResolvePixelLength(long emuLength, int fallback) { if (emuLength > 0) { return Math.Max(1, (int)Math.Round(emuLength / 9525d)); } return Math.Max(1, fallback <= 0 ? 1 : fallback); } private static DiffImageData CreateBitmapImageData(Bitmap bitmap, string extension) { using (var output = new MemoryStream()) { var normalizedExtension = NormalizeImageFormat(extension); bitmap.Save(output, GetImageFormat("." + normalizedExtension)); return CreateDiffImageData(normalizedExtension, bitmap.Width, bitmap.Height, output.ToArray()); } } private static DiffImageData CreateDiffImageData(string formatOrExtension, int width, int height, byte[] data) { return new DiffImageData { Format = NormalizeImageFormat(formatOrExtension), WidthPx = width, HeightPx = height, Data = Convert.ToBase64String(data ?? Array.Empty()), }; } private static string NormalizeImageFormat(string formatOrExtension) { var normalized = (formatOrExtension ?? string.Empty).Trim().TrimStart('.').ToLowerInvariant(); switch (normalized) { case "jpeg": return "jpg"; case "tiff": return "tif"; default: return normalized; } } private static void WriteImageData(ImageContext image, byte[] bytes, string targetFormat) { if (image == null) { throw new ArgumentNullException("image"); } var normalizedFormat = NormalizeImageFormat(targetFormat); if (!IsImagePartFormatMatch(image.PartUri, normalizedFormat)) { var originalPart = image.ImagePart; image.ParentPart.DeletePart(originalPart); image.ImagePart = CreateImagePart(image.ParentPart, GetImagePartType(normalizedFormat), image.RelationshipId); image.PartUri = image.ImagePart.Uri.ToString(); } using (var writeStream = image.ImagePart.GetStream(FileMode.Create, FileAccess.Write)) { writeStream.Write(bytes, 0, bytes.Length); } } private static ImagePart CreateImagePart(OpenXmlPart parentPart, PartTypeInfo partType, string relationshipId) { if (parentPart is MainDocumentPart mainDocumentPart) { return mainDocumentPart.AddImagePart(partType, relationshipId); } if (parentPart is HeaderPart headerPart) { return headerPart.AddImagePart(partType, relationshipId); } if (parentPart is FooterPart footerPart) { return footerPart.AddImagePart(partType, relationshipId); } if (parentPart is WordprocessingCommentsPart commentsPart) { return commentsPart.AddImagePart(partType, relationshipId); } if (parentPart is FootnotesPart footnotesPart) { return footnotesPart.AddImagePart(partType, relationshipId); } if (parentPart is EndnotesPart endnotesPart) { return endnotesPart.AddImagePart(partType, relationshipId); } if (parentPart is ChartPart chartPart) { return chartPart.AddImagePart(partType, relationshipId); } throw new NotSupportedException("Unsupported image parent part: " + parentPart.GetType().FullName); } private static bool IsImagePartFormatMatch(string partUri, string normalizedFormat) { var extension = (Path.GetExtension(partUri) ?? string.Empty).ToLowerInvariant(); switch (normalizedFormat) { case "jpg": return string.Equals(extension, ".jpg", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase); case "tif": return string.Equals(extension, ".tif", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".tiff", StringComparison.OrdinalIgnoreCase); default: return string.Equals(extension, "." + normalizedFormat, StringComparison.OrdinalIgnoreCase); } } private static PartTypeInfo GetImagePartType(string normalizedFormat) { switch (NormalizeImageFormat(normalizedFormat)) { case "jpg": return ImagePartType.Jpeg; case "bmp": return ImagePartType.Bmp; case "gif": return ImagePartType.Gif; case "tif": return ImagePartType.Tiff; case "wmf": return ImagePartType.Wmf; case "emf": return ImagePartType.Emf; case "svg": return ImagePartType.Svg; default: return ImagePartType.Png; } } private static bool IsLayoutEquivalent(DiffImageLayout current, DiffImageLayout expected, bool allowCompatibleWrapModes = false) { if (current == null && expected == null) { return true; } if (current == null || expected == null) { return false; } var wrapModesMatch = string.Equals(current.WrapMode ?? string.Empty, expected.WrapMode ?? string.Empty, StringComparison.OrdinalIgnoreCase) || (allowCompatibleWrapModes && AreCompatibleVmlWrapModes(current.WrapMode, expected.WrapMode)); return AreEmuLengthsEquivalent(current.DisplayWidthEmu, expected.DisplayWidthEmu) && AreEmuLengthsEquivalent(current.DisplayHeightEmu, expected.DisplayHeightEmu) && wrapModesMatch && Math.Abs(current.RotationDegree - expected.RotationDegree) <= 0.1d; } private static bool IsCompatibleImageObjectType(string expectedObjectType, string actualObjectType, string expectedPath, string actualPath) { if (string.IsNullOrWhiteSpace(expectedObjectType) || string.IsNullOrWhiteSpace(actualObjectType)) { return false; } if (string.Equals(expectedObjectType, actualObjectType, StringComparison.OrdinalIgnoreCase)) { return true; } var inlineOrFloating = (string.Equals(expectedObjectType, "inlineImage", StringComparison.OrdinalIgnoreCase) && string.Equals(actualObjectType, "floatingImage", StringComparison.OrdinalIgnoreCase)) || (string.Equals(expectedObjectType, "floatingImage", StringComparison.OrdinalIgnoreCase) && string.Equals(actualObjectType, "inlineImage", StringComparison.OrdinalIgnoreCase)); if (!inlineOrFloating) { return false; } return IsVmlLikeContainerPath(expectedPath) || IsVmlLikeContainerPath(actualPath); } private static bool AreCompatibleVmlWrapModes(string currentWrapMode, string expectedWrapMode) { var left = NormalizeWrapModeForCompatibility(currentWrapMode); var right = NormalizeWrapModeForCompatibility(expectedWrapMode); if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right)) { return false; } if (string.Equals(left, right, StringComparison.OrdinalIgnoreCase)) { return true; } var floatingModes = new HashSet(StringComparer.OrdinalIgnoreCase) { "inline", "anchor", "square", "tight", "through", "topBottom", "none", }; return floatingModes.Contains(left) && floatingModes.Contains(right); } private static string NormalizeWrapModeForCompatibility(string wrapMode) { if (string.IsNullOrWhiteSpace(wrapMode)) { return null; } var normalized = wrapMode.Trim(); if (string.Equals(normalized, "topandbottom", StringComparison.OrdinalIgnoreCase) || string.Equals(normalized, "top-bottom", StringComparison.OrdinalIgnoreCase)) { return "topBottom"; } return normalized; } private static bool IsVmlLikeContainerPath(string path) { if (string.IsNullOrWhiteSpace(path)) { return false; } return path.IndexOf("/pict[", StringComparison.OrdinalIgnoreCase) >= 0 || path.IndexOf("/object[", StringComparison.OrdinalIgnoreCase) >= 0; } private static bool AreEmuLengthsEquivalent(long current, long expected) { if (current == expected) { return true; } if (current <= 0 || expected <= 0) { return false; } var delta = Math.Abs(current - expected); var baseline = Math.Max(current, expected); const long absoluteToleranceEmu = 19050L; var relativeToleranceEmu = (long)Math.Ceiling(baseline * 0.02d); var tolerance = Math.Max(absoluteToleranceEmu, relativeToleranceEmu); return delta <= tolerance; } private static void SaveTouchedRoots(ISet touchedRoots, WordprocessingDocument document) { foreach (var root in touchedRoots) { root.Save(); } if (document.MainDocumentPart != null && document.MainDocumentPart.Document != null) { document.MainDocumentPart.Document.Save(); } } private static int CreateRandomSeed() { using (var rng = RandomNumberGenerator.Create()) { var buffer = new byte[4]; do { rng.GetBytes(buffer); var candidate = BitConverter.ToInt32(buffer, 0) & int.MaxValue; if (candidate != 0) { return candidate; } } while (true); } } private static int DeriveFixedSeed(int fixedSeed, string stableKey) { var input = Encoding.UTF8.GetBytes(fixedSeed.ToString() + "::" + (stableKey ?? string.Empty)); using (var sha = SHA256.Create()) { var hash = sha.ComputeHash(input); var seed = BitConverter.ToInt32(hash, 0) & int.MaxValue; return seed == 0 ? 1 : seed; } } private static double ApplyNoise(Bitmap bitmap, int seed) { if (bitmap == null || bitmap.Width <= 0 || bitmap.Height <= 0) { return 0; } var random = new Random(seed); var total = bitmap.Width * bitmap.Height; var target = Math.Min(total, (int)Math.Ceiling(total * NoiseTargetPixelRatio)); var modified = 0; var cells = CreateNoiseCells(bitmap.Width, bitmap.Height, target, random); foreach (var cell in cells) { modified += ApplyNoiseToCell(bitmap, cell, random); } return (double)modified / total; } private static IList CreateNoiseCells(int width, int height, int target, Random random) { var total = width * height; var assigned = 0; var cells = new List(); for (var y = 0; y < height; y += NoiseCellSize) { for (var x = 0; x < width; x += NoiseCellSize) { var cellWidth = Math.Min(NoiseCellSize, width - x); var cellHeight = Math.Min(NoiseCellSize, height - y); var area = cellWidth * cellHeight; var exactQuota = ((double)area * target) / total; var quota = (int)Math.Floor(exactQuota); assigned += quota; cells.Add(new NoiseCell { X = x, Y = y, Width = cellWidth, Height = cellHeight, Area = area, Quota = quota, Fraction = exactQuota - quota, TieBreaker = random.NextDouble(), }); } } var remaining = target - assigned; foreach (var cell in cells.OrderByDescending(item => item.Fraction).ThenBy(item => item.TieBreaker).Take(remaining)) { cell.Quota++; } return cells; } private static int ApplyNoiseToCell(Bitmap bitmap, NoiseCell cell, Random random) { if (cell.Quota <= 0) { return 0; } var indexes = Enumerable.Range(0, cell.Area).ToArray(); for (var i = 0; i < cell.Quota; i++) { var swapIndex = i + random.Next(cell.Area - i); var selected = indexes[swapIndex]; indexes[swapIndex] = indexes[i]; indexes[i] = selected; var x = cell.X + (selected % cell.Width); var y = cell.Y + (selected / cell.Width); var source = bitmap.GetPixel(x, y); bitmap.SetPixel(x, y, CreateNoiseColor(source, random)); } return cell.Quota; } private static Color CreateNoiseColor(Color source, Random random) { int r; int g; int b; switch (random.Next(5)) { case 0: r = 0; g = 0; b = 0; break; case 1: r = 255; g = 255; b = 255; break; case 2: r = 255 - source.R; g = 255 - source.G; b = 255 - source.B; break; case 3: r = random.Next(256); g = random.Next(256); b = random.Next(256); break; default: r = source.R < 128 ? random.Next(192, 256) : random.Next(0, 64); g = source.G < 128 ? random.Next(192, 256) : random.Next(0, 64); b = source.B < 128 ? random.Next(192, 256) : random.Next(0, 64); break; } if (r == source.R && g == source.G && b == source.B) { r = 255 - source.R; g = 255 - source.G; b = 255 - source.B; if (r == source.R && g == source.G && b == source.B) { r = (r + 127) % 256; } } return Color.FromArgb(source.A, r, g, b); } private static ImageFormat GetImageFormat(string extension) { switch ((extension ?? string.Empty).ToLowerInvariant()) { case ".jpg": case ".jpeg": return ImageFormat.Jpeg; case ".bmp": return ImageFormat.Bmp; case ".gif": return ImageFormat.Gif; case ".tif": case ".tiff": return ImageFormat.Tiff; default: return ImageFormat.Png; } } private static ParagraphTarget BuildParagraphTarget(PartContext context, W.Paragraph paragraph) { var container = paragraph.Parent; var paragraphIndex = 0; if (container != null) { paragraphIndex = container.ChildElements.OfType().TakeWhile(item => !ReferenceEquals(item, paragraph)).Count(); } var storyType = paragraph.Ancestors().Any(item => string.Equals(item.LocalName, "txbxContent", StringComparison.OrdinalIgnoreCase)) ? "textbox" : context.StoryType; return new ParagraphTarget { PartContext = context, ParagraphElement = paragraph, StoryType = storyType, ContainerPath = BuildContainerPath(context.PartUri, container, context.Root), ParagraphIndex = paragraphIndex, }; } private static ParagraphTarget BuildParagraphTarget(PartContext context, A.Paragraph paragraph) { var container = paragraph.Parent; var paragraphIndex = 0; if (container != null) { paragraphIndex = container.ChildElements.OfType().TakeWhile(item => !ReferenceEquals(item, paragraph)).Count(); } return new ParagraphTarget { PartContext = context, ParagraphElement = paragraph, StoryType = context.StoryType, ContainerPath = BuildContainerPath(context.PartUri, container, context.Root), ParagraphIndex = paragraphIndex, }; } private static IEnumerable EnumerateTextTargets(PartContext context) { if (context == null || context.Root == null) { yield break; } if (string.Equals(context.StoryType, "chart", StringComparison.OrdinalIgnoreCase)) { foreach (var paragraph in context.Root.Descendants()) { yield return BuildParagraphTarget(context, paragraph); } yield break; } foreach (var paragraph in context.Root.Descendants()) { yield return BuildParagraphTarget(context, paragraph); } } private static string BuildContainerPath(string partUri, OpenXmlElement container, OpenXmlElement root) { var builder = new StringBuilder(partUri ?? string.Empty); var elements = new Stack(); var current = container ?? root; while (current != null) { elements.Push(current); if (ReferenceEquals(current, root)) { break; } current = current.Parent; } while (elements.Count > 0) { var element = elements.Pop(); var parent = element.Parent; var index = 0; if (parent != null) { index = parent.ChildElements .OfType() .Where(item => string.Equals(item.LocalName, element.LocalName, StringComparison.OrdinalIgnoreCase)) .TakeWhile(item => !ReferenceEquals(item, element)) .Count(); } builder.Append('/'); builder.Append(element.LocalName); builder.Append('['); builder.Append(index); builder.Append(']'); } return builder.ToString(); } private static ParagraphState BuildParagraphState(ParagraphTarget target) { var textBuilder = new StringBuilder(); var leaves = new List(); var runList = new List(); var runOffsets = new Dictionary(); var logicalWordBoundaries = new HashSet(); TextLeaf previousLeaf = null; foreach (var element in target.ParagraphElement.Descendants()) { var leaf = element as OpenXmlLeafTextElement; if (leaf != null) { if (!IsSupportedTextLeaf(leaf) || !IsVisibleText(leaf)) { continue; } previousLeaf = AppendLeaf(leaves, textBuilder, runList, runOffsets, logicalWordBoundaries, previousLeaf, target.ParagraphElement, element, new TextLeaf { TextElement = leaf, Text = leaf.Text ?? string.Empty, IsEquationText = IsEquationLeaf(leaf), }) ?? previousLeaf; continue; } if (!VmlTextMetadataUtility.IsVmlShapeElement(element)) { continue; } foreach (var binding in VmlTextMetadataUtility.CreateBindings(element)) { previousLeaf = AppendLeaf(leaves, textBuilder, runList, runOffsets, logicalWordBoundaries, previousLeaf, target.ParagraphElement, element, new TextLeaf { Text = binding.Text ?? string.Empty, IsEquationText = binding.IsEquationText, IsChartText = binding.IsChartText, ApplyText = binding.Apply, }) ?? previousLeaf; } } return new ParagraphState { Target = target, Text = textBuilder.ToString(), Leaves = leaves, LogicalWordBoundaries = logicalWordBoundaries, }; } private static TextLeaf AppendLeaf( ICollection leaves, StringBuilder textBuilder, IList runList, IDictionary runOffsets, ISet logicalWordBoundaries, TextLeaf previousLeaf, OpenXmlElement paragraphElement, OpenXmlElement sourceElement, TextLeaf leaf) { if (leaf == null || string.IsNullOrEmpty(leaf.Text)) { return null; } var run = FindOwningRun(paragraphElement, sourceElement); if (run == null) { return null; } if (!runOffsets.ContainsKey(run)) { runOffsets[run] = 0; runList.Add(run); } var runIndex = runList.IndexOf(run); var runCharStart = runOffsets[run]; runOffsets[run] += leaf.Text.Length; if (ShouldCreateLogicalWordBoundary(previousLeaf, leaf)) { logicalWordBoundaries.Add(textBuilder.Length); } var globalStart = textBuilder.Length; textBuilder.Append(leaf.Text); leaf.Run = run; leaf.RunIndex = runIndex; leaf.RunCharStart = runCharStart; leaf.GlobalStart = globalStart; leaves.Add(leaf); return leaf; } private static bool ShouldCreateLogicalWordBoundary(TextLeaf previousLeaf, TextLeaf currentLeaf) { return previousLeaf != null && currentLeaf != null && (previousLeaf.IsChartText || currentLeaf.IsChartText); } private static bool IsSupportedTextLeaf(OpenXmlLeafTextElement text) { return text is W.Text || text is M.Text || text is A.Text; } private static OpenXmlElement FindOwningRun(OpenXmlElement paragraphElement, OpenXmlElement element) { var current = element == null ? null : element.Parent; while (current != null && !ReferenceEquals(current, paragraphElement)) { if (current is W.Run || current is M.Run || current is A.Run) { return current; } current = current.Parent; } return null; } private static bool IsEquationLeaf(OpenXmlLeafTextElement text) { return text != null && text.Ancestors().Any(item => string.Equals(item.LocalName, "oMath", StringComparison.OrdinalIgnoreCase) || string.Equals(item.LocalName, "oMathPara", StringComparison.OrdinalIgnoreCase)); } private static bool IsVisibleText(OpenXmlLeafTextElement text) { if (text is W.FieldCode || text is W.DeletedText) { return false; } if (text.Ancestors().Any()) { return false; } var run = text.Ancestors().FirstOrDefault(); if (run != null && run.RunProperties != null && run.RunProperties.Vanish != null) { return false; } return true; } private static List FindMatches(ParagraphState state, RuleDefinition rule) { var matches = new List(); var text = state == null ? null : state.Text; if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(rule.SearchText)) { return matches; } if (rule.MatchMode == RuleMatchMode.RegularExpression) { var pattern = rule.SearchText; var options = RegexOptions.CultureInvariant; if (!rule.IsCaseSensitive) { options |= RegexOptions.IgnoreCase; } foreach (Match match in Regex.Matches(text, pattern, options)) { if (!match.Success || match.Length <= 0) { continue; } if (rule.IsWholeWord && !IsWholeWord(text, state.LogicalWordBoundaries, match.Index, match.Length)) { continue; } matches.Add(new TextMatch { StartIndex = match.Index, Length = match.Length, OriginalText = match.Value, ReplacementTemplateResult = match.Result(rule.ReplacementText ?? string.Empty), }); } return matches; } var comparison = rule.IsCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; var start = 0; while (start < text.Length) { var index = text.IndexOf(rule.SearchText, start, comparison); if (index < 0) { break; } if (!rule.IsWholeWord || IsWholeWord(text, state.LogicalWordBoundaries, index, rule.SearchText.Length)) { matches.Add(new TextMatch { StartIndex = index, Length = rule.SearchText.Length, OriginalText = text.Substring(index, rule.SearchText.Length), ReplacementTemplateResult = rule.ReplacementText ?? string.Empty, }); } start = index + Math.Max(rule.SearchText.Length, 1); } return matches; } private static bool IsWholeWord(string text, ISet logicalWordBoundaries, int start, int length) { var leftOk = start <= 0 || (logicalWordBoundaries != null && logicalWordBoundaries.Contains(start)) || GetWordCharClass(text[start - 1]) != GetWordCharClass(text[start]); var rightIndex = start + length; var rightOk = rightIndex >= text.Length || (logicalWordBoundaries != null && logicalWordBoundaries.Contains(rightIndex)) || GetWordCharClass(text[rightIndex - 1]) != GetWordCharClass(text[rightIndex]); return leftOk && rightOk; } private static int GetWordCharClass(char character) { if ((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || character == '_') { return 1; } if (character >= 0x4E00 && character <= 0x9FFF) { return 2; } return 0; } private static DiffPosition BuildTextPosition(ParagraphState state, TextMatch match) { var startLeaf = state.Leaves.First(item => item.GlobalStart <= match.StartIndex && match.StartIndex < item.GlobalEnd); var endIndexExclusive = match.StartIndex + match.Length; var endLeaf = state.Leaves.First(item => item.GlobalStart < endIndexExclusive && endIndexExclusive <= item.GlobalEnd); return new DiffPosition { StoryType = state.Target.StoryType, ContainerPath = state.Target.ContainerPath, ParagraphIndex = state.Target.ParagraphIndex, ParagraphTextLength = -1, ParagraphTextHash = null, StartTextOffset = match.StartIndex, EndTextOffset = endIndexExclusive, StartRunIndex = startLeaf.RunIndex, StartCharOffset = startLeaf.RunCharStart + (match.StartIndex - startLeaf.GlobalStart), EndRunIndex = endLeaf.RunIndex, EndCharOffset = endLeaf.RunCharStart + (endIndexExclusive - endLeaf.GlobalStart), ObjectIndex = null, }; } private static string DetermineTextObjectType(ParagraphTarget target, ParagraphState state, TextMatch match, DiffPosition position) { if (string.Equals(position.StoryType, "chart", StringComparison.OrdinalIgnoreCase)) { return "chartText"; } var matchLeaves = state.Leaves .Where(item => item.GlobalStart < match.StartIndex + match.Length && match.StartIndex < item.GlobalEnd) .ToList(); if (matchLeaves.Any(item => item.IsChartText)) { return "chartText"; } if (string.Equals(target.StoryType, "textbox", StringComparison.OrdinalIgnoreCase)) { return "textbox"; } if (target.ParagraphElement.Ancestors().Any()) { return "tableCell"; } if (target.ParagraphElement.Descendants().Any()) { return "hyperlinkDisplay"; } if (matchLeaves.Any(IsFieldResultLeaf)) { return "fieldResult"; } if (matchLeaves.Any(item => item.IsEquationText)) { return "equationText"; } if (string.Equals(position.StoryType, "comment", StringComparison.OrdinalIgnoreCase)) { return "comment"; } if (string.Equals(position.StoryType, "footnote", StringComparison.OrdinalIgnoreCase)) { return "footnote"; } if (string.Equals(position.StoryType, "endnote", StringComparison.OrdinalIgnoreCase)) { return "endnote"; } return "paragraph"; } private static bool IsFieldResultLeaf(TextLeaf leaf) { if (leaf == null || leaf.TextElement == null) { return false; } if (leaf.TextElement.Ancestors().Any()) { return true; } var run = leaf.Run as W.Run; if (run == null) { return false; } return run.Descendants().Any() || run.ElementsBefore().OfType().Any(item => item.Descendants().Any(field => field.FieldCharType != null && field.FieldCharType.Value == W.FieldCharValues.Separate)); } private static void RewriteRange(ParagraphState state, int globalStart, int length, string replacement, bool updateFont = false) { var globalEnd = globalStart + length; var overlappingLeaves = state.Leaves .Where(item => item.GlobalStart < globalEnd && globalStart < item.GlobalEnd) .OrderBy(item => item.GlobalStart) .ToList(); var remaining = replacement ?? string.Empty; for (var index = 0; index < overlappingLeaves.Count; index++) { var leaf = overlappingLeaves[index]; var overlapStart = Math.Max(globalStart, leaf.GlobalStart); var overlapEnd = Math.Min(globalEnd, leaf.GlobalEnd); var localStart = overlapStart - leaf.GlobalStart; var localEnd = overlapEnd - leaf.GlobalStart; var originalSpanLength = localEnd - localStart; string assigned; if (index == overlappingLeaves.Count - 1) { assigned = remaining; remaining = string.Empty; } else { var take = Math.Min(originalSpanLength, remaining.Length); assigned = remaining.Substring(0, take); remaining = remaining.Substring(take); } var updatedText = leaf.Text.Substring(0, localStart) + assigned + leaf.Text.Substring(localEnd); leaf.Text = updatedText; if (leaf.ApplyText != null) { leaf.ApplyText(updatedText); } else if (leaf.TextElement != null) { leaf.TextElement.Text = updatedText; TrySetPreserveSpace(leaf.TextElement, updatedText); } if (updateFont && !string.IsNullOrEmpty(assigned)) { EnsureRunFont(leaf.Run, assigned); } } } private static void EnsureRunFont(OpenXmlElement run, string text) { var wordRun = run as W.Run; if (wordRun == null) { return; } var runProps = wordRun.RunProperties; if (runProps == null) { runProps = new W.RunProperties(); wordRun.PrependChild(runProps); } var runFonts = runProps.RunFonts; if (runFonts == null) { runFonts = new W.RunFonts(); runProps.PrependChild(runFonts); } var hasChinese = ContainsCjkCharacter(text); var hasEnglish = ContainsLatinLetter(text); if (hasChinese) { runFonts.EastAsia = "宋体"; runFonts.Ascii = "宋体"; runFonts.HighAnsi = "宋体"; } else if (hasEnglish) { runFonts.Ascii = "Times New Roman"; runFonts.HighAnsi = "Times New Roman"; } } private static bool ContainsCjkCharacter(string text) { foreach (var c in text) { if ((c >= 0x4E00 && c <= 0x9FFF) || (c >= 0x3400 && c <= 0x4DBF) || (c >= 0xF900 && c <= 0xFAFF)) { return true; } } return false; } private static bool ContainsLatinLetter(string text) { foreach (var c in text) { if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { return true; } } return false; } private static void TrySetPreserveSpace(OpenXmlLeafTextElement textElement, string updatedText) { var property = textElement.GetType().GetProperty("Space"); if (property == null || !property.CanWrite) { return; } if (NeedsPreserve(updatedText)) { property.SetValue(textElement, new EnumValue(SpaceProcessingModeValues.Preserve), null); } else { property.SetValue(textElement, null, null); } } private static TextRange ResolveRange(ParagraphState state, DiffOperation operation, string paragraphKey) { var position = operation == null ? null : operation.Position; var writtenText = operation == null ? string.Empty : (operation.WrittenText ?? string.Empty); if (position == null) { throw new InvalidDataException("恢复失败:差异操作缺少位置信息。"); } if (position.StartTextOffset >= 0) { var startIndexFromParagraph = position.StartTextOffset; var writtenLengthFromParagraph = writtenText.Length; if (startIndexFromParagraph <= state.Text.Length && startIndexFromParagraph + writtenLengthFromParagraph <= state.Text.Length) { return new TextRange { StartIndex = startIndexFromParagraph, Length = writtenLengthFromParagraph, }; } if (position.EndTextOffset >= startIndexFromParagraph && position.EndTextOffset <= state.Text.Length) { return new TextRange { StartIndex = startIndexFromParagraph, Length = position.EndTextOffset - startIndexFromParagraph, }; } } var startLeaf = state.Leaves.FirstOrDefault(item => item.RunIndex == position.StartRunIndex && item.RunCharStart <= position.StartCharOffset && position.StartCharOffset <= item.RunCharEnd); if (startLeaf == null) { throw new InvalidDataException("无法定位恢复起始位置。 " + BuildRestoreOperationContext(operation, paragraphKey, state)); } var startIndex = startLeaf.GlobalStart + (position.StartCharOffset - startLeaf.RunCharStart); var writtenLength = writtenText.Length; if (startIndex + writtenLength <= state.Text.Length) { return new TextRange { StartIndex = startIndex, Length = writtenLength, }; } var endLeaf = state.Leaves.FirstOrDefault(item => item.RunIndex == position.EndRunIndex && item.RunCharStart <= position.EndCharOffset && position.EndCharOffset <= item.RunCharEnd); if (endLeaf == null) { throw new InvalidDataException("无法定位恢复结束位置。 " + BuildRestoreOperationContext(operation, paragraphKey, state)); } var endIndex = endLeaf.GlobalStart + (position.EndCharOffset - endLeaf.RunCharStart); if (endIndex < startIndex) { throw new InvalidDataException("恢复位置非法。 " + BuildRestoreOperationContext(operation, paragraphKey, state)); } return new TextRange { StartIndex = startIndex, Length = endIndex - startIndex, }; } private static string BuildParagraphLookupKey(string storyType, string containerPath, int paragraphIndex) { return (storyType ?? string.Empty) + "|" + (containerPath ?? string.Empty) + "|" + paragraphIndex; } private static string BuildRestoreOperationContext(DiffOperation operation, string paragraphKey, ParagraphState state) { var builder = new StringBuilder(); if (operation != null) { builder.Append("OperationId=").Append(operation.OperationId ?? string.Empty); builder.Append("; Type=").Append(operation.Type ?? string.Empty); builder.Append("; ObjectType=").Append(operation.ObjectType ?? string.Empty); if (operation.Rule != null) { builder.Append("; RuleId=").Append(operation.Rule.Id ?? string.Empty); builder.Append("; RuleName=").Append(operation.Rule.Name ?? string.Empty); builder.Append("; Pattern=").Append(operation.Rule.Pattern ?? string.Empty); } builder.Append("; Position=").Append(FormatPosition(operation.Position)); builder.Append("; OriginalText=").Append(operation.OriginalText ?? string.Empty); builder.Append("; WrittenText=").Append(operation.WrittenText ?? string.Empty); } builder.Append("; ParagraphKey=").Append(paragraphKey ?? string.Empty); if (state != null) { builder.Append("; ParagraphLength=").Append(state.Text == null ? "0" : state.Text.Length.ToString(CultureInfo.InvariantCulture)); builder.Append("; ParagraphText=").Append(state.Text ?? string.Empty); builder.Append("; ParagraphHash=").Append(BuildParagraphContentHash(state.Text)); builder.Append("; Leaves=").Append(FormatLeaves(state.Leaves)); } return builder.ToString(); } private static string FormatPosition(DiffPosition position) { if (position == null) { return ""; } return string.Format( CultureInfo.InvariantCulture, "story={0},container={1},paragraph={2},paragraphTextLength={3},paragraphTextHash={4},textStart={5},textEnd={6},runStart={7}:{8},runEnd={9}:{10},objectIndex={11}", position.StoryType ?? string.Empty, position.ContainerPath ?? string.Empty, position.ParagraphIndex, position.ParagraphTextLength, position.ParagraphTextHash ?? "", position.StartTextOffset, position.EndTextOffset, position.StartRunIndex, position.StartCharOffset, position.EndRunIndex, position.EndCharOffset, position.ObjectIndex.HasValue ? position.ObjectIndex.Value.ToString(CultureInfo.InvariantCulture) : ""); } private static string BuildParagraphScopeKey(string storyType, string containerPath) { return (storyType ?? string.Empty) + "|" + (containerPath ?? string.Empty); } private static string BuildParagraphContentHash(string text) { return HashUtility.ComputeSha256Base64(text ?? string.Empty); } private static bool TryResolveRestoreTargetFallback( IDictionary> paragraphScopeLookup, string scopeKey, DiffOperation operation, out ParagraphTarget target, out ParagraphState state, out TextRange range, out string actualWritten, out bool ambiguous) { target = null; state = null; range = null; actualWritten = null; ambiguous = false; if (paragraphScopeLookup == null || string.IsNullOrEmpty(scopeKey) || !paragraphScopeLookup.TryGetValue(scopeKey, out var candidates)) { return false; } var resolvedCandidates = new List(); foreach (var candidate in candidates) { var candidateState = BuildParagraphState(candidate); var strictMatch = TryResolveOperationInParagraph(candidateState, operation, out var candidateRange, out var candidateWritten); var startOffsetDistance = 0; if (!strictMatch && !TryResolveOperationByWrittenText(candidateState, operation, out candidateRange, out candidateWritten, out startOffsetDistance)) { continue; } resolvedCandidates.Add(new ResolvedParagraphCandidate { Target = candidate, State = candidateState, Range = candidateRange, ActualWritten = candidateWritten, IsStrictMatch = strictMatch, BoundaryPenalty = ComputeRangeBoundaryPenalty(candidateState, candidateRange), StartOffsetDistance = strictMatch ? ComputeStartOffsetDistance(operation == null ? null : operation.Position, candidateRange) : startOffsetDistance, }); } if (resolvedCandidates.Count == 0) { return false; } var filteredCandidates = resolvedCandidates; if (filteredCandidates.Any(item => item.IsStrictMatch)) { filteredCandidates = filteredCandidates .Where(item => item.IsStrictMatch) .ToList(); } if (filteredCandidates.Count > 1) { var bestBoundaryPenalty = filteredCandidates.Min(item => item.BoundaryPenalty); filteredCandidates = filteredCandidates .Where(item => item.BoundaryPenalty == bestBoundaryPenalty) .ToList(); } var position = operation == null ? null : operation.Position; if (position != null && position.ParagraphIndex >= 0 && filteredCandidates.Count > 1) { var bestDistance = filteredCandidates .Min(item => Math.Abs(item.Target.ParagraphIndex - position.ParagraphIndex)); filteredCandidates = filteredCandidates .Where(item => Math.Abs(item.Target.ParagraphIndex - position.ParagraphIndex) == bestDistance) .ToList(); } if (position != null && position.StartTextOffset >= 0 && filteredCandidates.Count > 1) { var bestOffsetDistance = filteredCandidates.Min(item => item.StartOffsetDistance); filteredCandidates = filteredCandidates .Where(item => item.StartOffsetDistance == bestOffsetDistance) .ToList(); } if (filteredCandidates.Count != 1) { ambiguous = true; return false; } var resolved = filteredCandidates[0]; target = resolved.Target; state = resolved.State; range = resolved.Range; actualWritten = resolved.ActualWritten; return true; } private static bool TryResolveOperationInParagraph(ParagraphState state, DiffOperation operation, out TextRange range, out string actualWritten) { range = null; actualWritten = null; if (state == null || !TryResolveRange(state, operation, out range)) { return false; } actualWritten = ExtractTextRange(state, range); if (!string.Equals(actualWritten, operation == null ? string.Empty : (operation.WrittenText ?? string.Empty), StringComparison.Ordinal)) { return false; } return MatchesParagraphLength(operation == null ? null : operation.Position, state); } private static bool MatchesParagraphLength(DiffPosition position, ParagraphState state) { if (position == null || state == null) { return true; } if (position.ParagraphTextLength < 0) { return true; } var currentLength = (state.Text ?? string.Empty).Length; return position.ParagraphTextLength == currentLength; } private static bool TryResolveOperationByWrittenText( ParagraphState state, DiffOperation operation, out TextRange range, out string actualWritten, out int startOffsetDistance) { range = null; actualWritten = null; startOffsetDistance = int.MaxValue; if (state == null || operation == null) { return false; } var writtenText = operation.WrittenText ?? string.Empty; if (string.IsNullOrEmpty(writtenText) || !MatchesParagraphIdentity(operation.Position, state)) { return false; } var bestIndex = -1; var bestBoundaryPenalty = int.MaxValue; var searchStart = 0; while (searchStart <= state.Text.Length - writtenText.Length) { var matchIndex = state.Text.IndexOf(writtenText, searchStart, StringComparison.Ordinal); if (matchIndex < 0) { break; } var candidateRange = new TextRange { StartIndex = matchIndex, Length = writtenText.Length, }; var currentBoundaryPenalty = ComputeRangeBoundaryPenalty(state, candidateRange); var currentDistance = ComputeStartOffsetDistance(operation.Position, candidateRange); if (bestIndex < 0 || currentBoundaryPenalty < bestBoundaryPenalty || (currentBoundaryPenalty == bestBoundaryPenalty && currentDistance < startOffsetDistance)) { bestIndex = matchIndex; bestBoundaryPenalty = currentBoundaryPenalty; startOffsetDistance = currentDistance; } searchStart = matchIndex + Math.Max(writtenText.Length, 1); } if (bestIndex < 0) { return false; } range = new TextRange { StartIndex = bestIndex, Length = writtenText.Length, }; actualWritten = writtenText; return true; } private static bool MatchesParagraphIdentity(DiffPosition position, ParagraphState state) { if (position == null || state == null) { return true; } var text = state.Text ?? string.Empty; if (position.ParagraphTextLength >= 0 && position.ParagraphTextLength != text.Length) { return false; } if (!string.IsNullOrEmpty(position.ParagraphTextHash) && !string.Equals(position.ParagraphTextHash, BuildParagraphContentHash(text), StringComparison.Ordinal)) { return false; } return true; } private static bool TryResolveRange(ParagraphState state, DiffOperation operation, out TextRange range) { range = null; var position = operation == null ? null : operation.Position; var writtenText = operation == null ? string.Empty : (operation.WrittenText ?? string.Empty); if (state == null || position == null) { return false; } if (position.StartTextOffset >= 0) { var startIndexFromParagraph = position.StartTextOffset; var writtenLengthFromParagraph = writtenText.Length; if (startIndexFromParagraph <= state.Text.Length && startIndexFromParagraph + writtenLengthFromParagraph <= state.Text.Length) { range = new TextRange { StartIndex = startIndexFromParagraph, Length = writtenLengthFromParagraph, }; return true; } if (position.EndTextOffset >= startIndexFromParagraph && position.EndTextOffset <= state.Text.Length) { range = new TextRange { StartIndex = startIndexFromParagraph, Length = position.EndTextOffset - startIndexFromParagraph, }; return true; } } var startLeaf = state.Leaves.FirstOrDefault(item => item.RunIndex == position.StartRunIndex && item.RunCharStart <= position.StartCharOffset && position.StartCharOffset <= item.RunCharEnd); if (startLeaf == null) { return false; } var startIndex = startLeaf.GlobalStart + (position.StartCharOffset - startLeaf.RunCharStart); var writtenLength = writtenText.Length; if (startIndex + writtenLength <= state.Text.Length) { range = new TextRange { StartIndex = startIndex, Length = writtenLength, }; return true; } var endLeaf = state.Leaves.FirstOrDefault(item => item.RunIndex == position.EndRunIndex && item.RunCharStart <= position.EndCharOffset && position.EndCharOffset <= item.RunCharEnd); if (endLeaf == null) { return false; } var endIndex = endLeaf.GlobalStart + (position.EndCharOffset - endLeaf.RunCharStart); if (endIndex < startIndex) { return false; } range = new TextRange { StartIndex = startIndex, Length = endIndex - startIndex, }; return true; } private static TextRange TryCreateDiagnosticRange(ParagraphState state, DiffOperation operation) { return TryResolveRange(state, operation, out var range) ? range : null; } private static int ComputeStartOffsetDistance(DiffPosition position, TextRange range) { if (position == null || range == null || position.StartTextOffset < 0) { return 0; } return Math.Abs(range.StartIndex - position.StartTextOffset); } private static bool ShouldTrustExactRestoreCandidate(DiffOperation operation, ParagraphState state, TextRange range) { if (operation == null) { return true; } if (HasStrongParagraphIdentity(operation.Position)) { return true; } return ComputeRangeBoundaryPenalty(state, range) == 0; } private static bool HasStrongParagraphIdentity(DiffPosition position) { return position != null && (position.ParagraphTextLength >= 0 || !string.IsNullOrEmpty(position.ParagraphTextHash)); } private static int ComputeRangeBoundaryPenalty(ParagraphState state, TextRange range) { if (state == null || string.IsNullOrEmpty(state.Text) || range == null || range.Length <= 0) { return 0; } if (range.StartIndex < 0 || range.StartIndex + range.Length > state.Text.Length) { return int.MaxValue; } var penalty = 0; var matchedText = ExtractTextRange(state, range); if (string.IsNullOrEmpty(matchedText)) { return 0; } if (range.StartIndex > 0 && state.Text[range.StartIndex - 1] == matchedText[0]) { penalty++; } var endIndex = range.StartIndex + range.Length; if (endIndex < state.Text.Length && state.Text[endIndex] == matchedText[matchedText.Length - 1]) { penalty++; } return penalty; } private static string ExtractTextRange(ParagraphState state, TextRange range) { if (state == null || string.IsNullOrEmpty(state.Text) || range == null) { return string.Empty; } if (range.StartIndex < 0 || range.Length < 0 || range.StartIndex + range.Length > state.Text.Length) { return string.Empty; } return range.Length == 0 ? string.Empty : state.Text.Substring(range.StartIndex, range.Length); } private static string FormatLeaves(IEnumerable leaves) { if (leaves == null) { return ""; } return string.Join( " | ", leaves.Select(item => string.Format( CultureInfo.InvariantCulture, "run={0},runRange=[{1},{2}],globalRange=[{3},{4}],text={5}", item == null ? -1 : item.RunIndex, item == null ? -1 : item.RunCharStart, item == null ? -1 : item.RunCharEnd, item == null ? -1 : item.GlobalStart, item == null ? -1 : item.GlobalEnd, item == null ? string.Empty : item.Text ?? string.Empty))); } private static bool NeedsPreserve(string value) { return !string.IsNullOrEmpty(value) && (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[value.Length - 1]) || value.Contains(" ")); } private sealed class ProcessedImageResult { public string SourceKind { get; set; } public DiffImageData OriginalImage { get; set; } public DiffImageData ConvertedBitmapImage { get; set; } public DiffImageData NewImage { get; set; } public double ModifiedPixelRatio { get; set; } public int Seed { get; set; } } private sealed class NoiseCell { public int X { get; set; } public int Y { get; set; } public int Width { get; set; } public int Height { get; set; } public int Area { get; set; } public int Quota { get; set; } public double Fraction { get; set; } public double TieBreaker { get; set; } } private class PartContext { public OpenXmlPart Part { get; set; } public OpenXmlPartRootElement Root { get; set; } public string PartUri { get; set; } public string StoryType { get; set; } } private class ParagraphTarget { public PartContext PartContext { get; set; } public OpenXmlElement ParagraphElement { get; set; } public string StoryType { get; set; } public string ContainerPath { get; set; } public int ParagraphIndex { get; set; } } private class ParagraphState { public ParagraphTarget Target { get; set; } public string Text { get; set; } public List Leaves { get; set; } public ISet LogicalWordBoundaries { get; set; } } private class TextLeaf { public OpenXmlLeafTextElement TextElement { get; set; } public OpenXmlElement Run { get; set; } public int RunIndex { get; set; } public int RunCharStart { get; set; } public int RunCharEnd { get { return RunCharStart + (Text == null ? 0 : Text.Length); } } public int GlobalStart { get; set; } public int GlobalEnd { get { return GlobalStart + (Text == null ? 0 : Text.Length); } } public string Text { get; set; } public bool IsEquationText { get; set; } public bool IsChartText { get; set; } public Action ApplyText { get; set; } } private class TextMatch { public int StartIndex { get; set; } public int Length { get; set; } public string OriginalText { get; set; } public string ReplacementTemplateResult { get; set; } } private class TextRange { public int StartIndex { get; set; } public int Length { get; set; } } private class ResolvedParagraphCandidate { public ParagraphTarget Target { get; set; } public ParagraphState State { get; set; } public TextRange Range { get; set; } public string ActualWritten { get; set; } public bool IsStrictMatch { get; set; } public int BoundaryPenalty { get; set; } public int StartOffsetDistance { get; set; } } private class ImageContext { public OpenXmlPart ParentPart { get; set; } public ImagePart ImagePart { get; set; } public string RelationshipId { get; set; } public string RelationKey { get; set; } public string PartUri { get; set; } public string StoryType { get; set; } public string ContainerPath { get; set; } public string ContainerKind { get; set; } public string EmbeddedProgramId { get; set; } public string EmbeddedPackageExtension { get; set; } public string ObjectType { get; set; } public DiffImageLayout Layout { get; set; } public int ObjectIndex { get; set; } } private class ImageRestoreCandidate { public ImageContext Image { get; set; } public bool IsProcessed { get; set; } public bool StoryMatches { get; set; } public bool ContainerKindMatches { get; set; } public bool EmbeddedProgramMatches { get; set; } public bool EmbeddedPackageMatches { get; set; } public bool ObjectTypeMatches { get; set; } public bool ObjectTypeCompatible { get; set; } public bool LayoutMatches { get; set; } public bool ContentMatches { get; set; } public bool SkipContentVerification { get; set; } public int PathSimilarity { get; set; } public int ObjectIndexDistance { get; set; } } private sealed class ReplacementOutputPaths { public string OutputPath { get; set; } public string DiffPath { get; set; } public string BmpPath { get; set; } } private sealed class BrokenFieldFinding { public string StoryType { get; set; } public string ContainerPath { get; set; } public int ParagraphIndex { get; set; } public string Marker { get; set; } public string Excerpt { get; set; } public string FullParagraphText { get; set; } public int? TableIndex { get; set; } public int? RowIndex { get; set; } public int? ColumnIndex { get; set; } public string PreviousParagraphText { get; set; } public string NextParagraphText { get; set; } public string SameRowCellsText { get; set; } public string SameColumnCellsText { get; set; } public string Identity => string.Format( CultureInfo.InvariantCulture, "{0}|{1}|{2}|{3}", StoryType ?? string.Empty, ContainerPath ?? string.Empty, ParagraphIndex, Marker ?? string.Empty); } } }