4434 lines
174 KiB
C#
4434 lines
174 KiB
C#
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<FileProcessProgress> 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<RuleDefinition>() : 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<OpenXmlPartRootElement>();
|
|
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<FileProcessProgress> 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<OpenXmlPartRootElement>();
|
|
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<DiffOperation>();
|
|
}
|
|
|
|
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<FileProcessProgress> 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<BrokenFieldFinding> ScanBrokenFieldResults(string workingDocxPath)
|
|
{
|
|
var findings = new List<BrokenFieldFinding>();
|
|
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<BrokenFieldFinding> baselineFindings,
|
|
string operationName,
|
|
IProgress<FileProcessProgress> progress)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(workingDocxPath) || !File.Exists(workingDocxPath))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var currentFindings = ScanBrokenFieldResults(workingDocxPath);
|
|
if (currentFindings.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var baselineIdentities = new HashSet<string>(
|
|
(baselineFindings ?? Enumerable.Empty<BrokenFieldFinding>()).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<W.Text>())
|
|
{
|
|
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<W.TableCell>())
|
|
{
|
|
if (sb.Length > 0)
|
|
{
|
|
sb.Append(" | ");
|
|
}
|
|
|
|
var firstPara = cell.Descendants<W.Paragraph>().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<W.TableCell>().ToList();
|
|
hitColumnIndex = cells.TakeWhile(c => !ReferenceEquals(c, hitCell)).Count();
|
|
var rows = table.Elements<W.TableRow>().ToList();
|
|
hitRowIndex = rows.TakeWhile(r => !ReferenceEquals(r, row)).Count();
|
|
}
|
|
|
|
var sb = new StringBuilder();
|
|
var rowIdx = 0;
|
|
foreach (var currentRow in table.Elements<W.TableRow>())
|
|
{
|
|
var cells = currentRow.Elements<W.TableCell>().ToList();
|
|
if (hitColumnIndex < cells.Count)
|
|
{
|
|
if (sb.Length > 0)
|
|
{
|
|
sb.Append(" | ");
|
|
}
|
|
|
|
var firstPara = cells[hitColumnIndex].Descendants<W.Paragraph>().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<RuleDefinition> 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<PartContext> 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<OpenXmlPartContainer>();
|
|
var visited = new HashSet<OpenXmlPart>();
|
|
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<ImageContext> EnumerateImageParts(WordprocessingDocument document)
|
|
{
|
|
foreach (var partContext in EnumerateTextParts(document))
|
|
{
|
|
var drawings = partContext.Root.Descendants<W.Drawing>().ToList();
|
|
for (var index = 0; index < drawings.Count; index++)
|
|
{
|
|
var drawing = drawings[index];
|
|
var blip = drawing.Descendants<A.Blip>()
|
|
.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<W.EmbeddedObject>().Any())
|
|
|| item is W.EmbeddedObject)
|
|
.ToList();
|
|
for (var index = 0; index < vmlContainers.Count; index++)
|
|
{
|
|
var containerElement = vmlContainers[index];
|
|
var imageData = containerElement.Descendants<V.ImageData>()
|
|
.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<DW.Inline>();
|
|
var anchor = drawing.GetFirstChild<DW.Anchor>();
|
|
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<A.Transform2D>().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<V.Shape>().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<DW.WrapSquare>().Any())
|
|
{
|
|
return "square";
|
|
}
|
|
|
|
if (anchor.Elements<DW.WrapTight>().Any())
|
|
{
|
|
return "tight";
|
|
}
|
|
|
|
if (anchor.Elements<DW.WrapThrough>().Any())
|
|
{
|
|
return "through";
|
|
}
|
|
|
|
if (anchor.Elements<DW.WrapTopBottom>().Any())
|
|
{
|
|
return "topBottom";
|
|
}
|
|
|
|
if (anchor.Elements<DW.WrapNone>().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<string, string> ParseVmlStyle(string style)
|
|
{
|
|
var map = new Dictionary<string, string>(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<string, string> styleMap, string key)
|
|
{
|
|
if (styleMap == null || !styleMap.TryGetValue(key, out var rawValue) || string.IsNullOrWhiteSpace(rawValue))
|
|
{
|
|
return 0L;
|
|
}
|
|
|
|
var match = Regex.Match(rawValue.Trim(), @"^(?<value>-?\d+(?:\.\d+)?)(?<unit>[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<string, string> 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<string, string> 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<string, string> 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<RuleDefinition> rules,
|
|
DiffPayload payload,
|
|
ISet<OpenXmlPartRootElement> touchedRoots,
|
|
IProgress<FileProcessProgress> 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<FileProcessProgress> progress,
|
|
string filePath,
|
|
CancellationToken cancellationToken,
|
|
ISet<OpenXmlPartRootElement> 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<string, ProcessedImageResult>(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<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
var processedRelations = new HashSet<string>(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<ImageContext> images,
|
|
DiffOperation operation,
|
|
IDictionary<string, string> imageBytesCache,
|
|
ISet<string> 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<ImageContext> images,
|
|
DiffOperation operation,
|
|
IDictionary<string, string> imageBytesCache,
|
|
ISet<string> 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<string, string> imageBytesCache,
|
|
ISet<string> 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<string, string> 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 "<null>";
|
|
}
|
|
|
|
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) : "<null>",
|
|
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<SvgDocument>(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<byte>()),
|
|
};
|
|
}
|
|
|
|
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<string>(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<OpenXmlPartRootElement> 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<NoiseCell> CreateNoiseCells(int width, int height, int target, Random random)
|
|
{
|
|
var total = width * height;
|
|
var assigned = 0;
|
|
var cells = new List<NoiseCell>();
|
|
|
|
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<W.Paragraph>().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<A.Paragraph>().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<ParagraphTarget> 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<A.Paragraph>())
|
|
{
|
|
yield return BuildParagraphTarget(context, paragraph);
|
|
}
|
|
|
|
yield break;
|
|
}
|
|
|
|
foreach (var paragraph in context.Root.Descendants<W.Paragraph>())
|
|
{
|
|
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<OpenXmlElement>();
|
|
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<OpenXmlElement>()
|
|
.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<TextLeaf>();
|
|
var runList = new List<OpenXmlElement>();
|
|
var runOffsets = new Dictionary<OpenXmlElement, int>();
|
|
var logicalWordBoundaries = new HashSet<int>();
|
|
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<TextLeaf> leaves,
|
|
StringBuilder textBuilder,
|
|
IList<OpenXmlElement> runList,
|
|
IDictionary<OpenXmlElement, int> runOffsets,
|
|
ISet<int> 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<W.DeletedRun>().Any())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var run = text.Ancestors<W.Run>().FirstOrDefault();
|
|
if (run != null && run.RunProperties != null && run.RunProperties.Vanish != null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static List<TextMatch> FindMatches(ParagraphState state, RuleDefinition rule)
|
|
{
|
|
var matches = new List<TextMatch>();
|
|
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<int> 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<W.TableCell>().Any())
|
|
{
|
|
return "tableCell";
|
|
}
|
|
|
|
if (target.ParagraphElement.Descendants<W.Hyperlink>().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<W.SimpleField>().Any())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var run = leaf.Run as W.Run;
|
|
if (run == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return run.Descendants<W.FieldChar>().Any()
|
|
|| run.ElementsBefore().OfType<W.Run>().Any(item => item.Descendants<W.FieldChar>().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>(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 "<null>";
|
|
}
|
|
|
|
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 ?? "<null>",
|
|
position.StartTextOffset,
|
|
position.EndTextOffset,
|
|
position.StartRunIndex,
|
|
position.StartCharOffset,
|
|
position.EndRunIndex,
|
|
position.EndCharOffset,
|
|
position.ObjectIndex.HasValue ? position.ObjectIndex.Value.ToString(CultureInfo.InvariantCulture) : "<null>");
|
|
}
|
|
|
|
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<string, List<ParagraphTarget>> 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<ResolvedParagraphCandidate>();
|
|
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<TextLeaf> leaves)
|
|
{
|
|
if (leaves == null)
|
|
{
|
|
return "<null>";
|
|
}
|
|
|
|
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<TextLeaf> Leaves { get; set; }
|
|
|
|
public ISet<int> 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<string> 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);
|
|
}
|
|
}
|
|
} |