first commit
This commit is contained in:
550
DCIT.Core/Services/AutoExtractService.cs
Normal file
550
DCIT.Core/Services/AutoExtractService.cs
Normal file
@@ -0,0 +1,550 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DCIT.Core.Models;
|
||||
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public class AutoExtractService
|
||||
{
|
||||
private static readonly Regex TokenRegex = new Regex(
|
||||
@"(?<Email>[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})|(?<English>[A-Za-z][A-Za-z0-9_\-']*)|(?<Chinese>[\u4E00-\u9FFF]+)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private readonly WordTextExtractionService _wordTextExtractionService;
|
||||
|
||||
public AutoExtractService(WordTextExtractionService wordTextExtractionService)
|
||||
{
|
||||
_wordTextExtractionService = wordTextExtractionService;
|
||||
}
|
||||
|
||||
public Task<AutoExtractResult> ExtractAsync(
|
||||
IList<string> selectedFiles,
|
||||
AutoExtractSettings settings,
|
||||
IList<RuleDefinition> existingRules,
|
||||
IProgress<ExtractionProgress> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.Run(
|
||||
() => Execute(selectedFiles, settings, existingRules, progress, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private AutoExtractResult Execute(
|
||||
IList<string> selectedFiles,
|
||||
AutoExtractSettings settings,
|
||||
IList<RuleDefinition> existingRules,
|
||||
IProgress<ExtractionProgress> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new AutoExtractResult();
|
||||
var extractedDocuments = new List<ExtractedDocumentText>();
|
||||
|
||||
if (selectedFiles == null || selectedFiles.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
settings = settings ?? new AutoExtractSettings();
|
||||
existingRules = existingRules ?? Array.Empty<RuleDefinition>();
|
||||
|
||||
for (var index = 0; index < selectedFiles.Count; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var filePath = selectedFiles[index];
|
||||
progress?.Report(new ExtractionProgress
|
||||
{
|
||||
Current = index + 1,
|
||||
Total = selectedFiles.Count,
|
||||
Message = "正在分析 " + filePath,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var extracted = _wordTextExtractionService.ExtractVisibleText(filePath);
|
||||
if (!string.IsNullOrWhiteSpace(extracted.VisibleText))
|
||||
{
|
||||
extractedDocuments.Add(extracted);
|
||||
result.DocumentsProcessed++;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.DocumentsSkipped++;
|
||||
result.Warnings.Add(filePath + " 未提取到可见文本。");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.DocumentsSkipped++;
|
||||
result.Warnings.Add(filePath + " 提取失败: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
if (extractedDocuments.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var candidateMap = new Dictionary<string, ExtractionCandidate>(StringComparer.Ordinal);
|
||||
var existingSearchTexts = new HashSet<string>(
|
||||
existingRules
|
||||
.Where(rule => !string.IsNullOrWhiteSpace(rule.SearchText))
|
||||
.Select(rule => rule.SearchText),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
if (settings.ExtractKeywords)
|
||||
{
|
||||
foreach (var keyword in BuildKeywordCandidates(extractedDocuments, settings))
|
||||
{
|
||||
AddCandidate(candidateMap, existingSearchTexts, keyword);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.ExtractHighFrequencyWords)
|
||||
{
|
||||
foreach (var word in BuildHighFrequencyWordCandidates(extractedDocuments, settings))
|
||||
{
|
||||
AddCandidate(candidateMap, existingSearchTexts, word);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.ExtractHighFrequencySentences)
|
||||
{
|
||||
foreach (var sentence in BuildSentenceCandidates(extractedDocuments, settings))
|
||||
{
|
||||
AddCandidate(candidateMap, existingSearchTexts, sentence);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var candidate in candidateMap.Values.OrderBy(item => item.FirstOccurrenceOrder))
|
||||
{
|
||||
result.Candidates.Add(candidate);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private IEnumerable<ExtractionCandidate> BuildKeywordCandidates(IList<ExtractedDocumentText> documents, AutoExtractSettings settings)
|
||||
{
|
||||
var tokenObservations = BuildTokenObservations(documents, settings).ToList();
|
||||
if (tokenObservations.Count == 0)
|
||||
{
|
||||
return Enumerable.Empty<ExtractionCandidate>();
|
||||
}
|
||||
|
||||
var candidates = new List<ExtractionCandidate>();
|
||||
if (settings.UseKeywordFrequency)
|
||||
{
|
||||
candidates.AddRange(CreateCandidatesFromRankedEntries(
|
||||
RankByFrequency(tokenObservations).Take(settings.MaxKeywordCount),
|
||||
AutoExtractCategory.Keyword,
|
||||
"关键字(词频)"));
|
||||
}
|
||||
|
||||
if (settings.UseKeywordTfIdf)
|
||||
{
|
||||
candidates.AddRange(CreateCandidatesFromRankedEntries(
|
||||
RankByTfIdf(tokenObservations).Take(settings.MaxKeywordCount),
|
||||
AutoExtractCategory.Keyword,
|
||||
"关键字(TF-IDF)"));
|
||||
}
|
||||
|
||||
if (settings.UseKeywordTextRank)
|
||||
{
|
||||
candidates.AddRange(CreateCandidatesFromRankedEntries(
|
||||
RankByTextRank(tokenObservations).Take(settings.MaxKeywordCount),
|
||||
AutoExtractCategory.Keyword,
|
||||
"关键字(TextRank)"));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private IEnumerable<ExtractionCandidate> BuildHighFrequencyWordCandidates(IList<ExtractedDocumentText> documents, AutoExtractSettings settings)
|
||||
{
|
||||
var tokenObservations = BuildTokenObservations(documents, settings).ToList();
|
||||
return CreateCandidatesFromRankedEntries(
|
||||
RankByFrequency(tokenObservations).Take(settings.MaxWordCount),
|
||||
AutoExtractCategory.HighFrequencyWord,
|
||||
"高频词");
|
||||
}
|
||||
|
||||
private IEnumerable<ExtractionCandidate> BuildSentenceCandidates(IList<ExtractedDocumentText> documents, AutoExtractSettings settings)
|
||||
{
|
||||
var sentenceMap = new Dictionary<string, RankedEntry>(StringComparer.Ordinal);
|
||||
var order = 0;
|
||||
var delimiters = new HashSet<char>((settings.SentenceDelimiters ?? string.Empty).ToCharArray());
|
||||
|
||||
foreach (var document in documents)
|
||||
{
|
||||
foreach (var sentence in SplitSentences(document.VisibleText, delimiters))
|
||||
{
|
||||
var trimmed = sentence.Trim();
|
||||
if (!IsAllowedSentence(trimmed, settings))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
order++;
|
||||
RankedEntry entry;
|
||||
if (!sentenceMap.TryGetValue(trimmed, out entry))
|
||||
{
|
||||
entry = new RankedEntry
|
||||
{
|
||||
Text = trimmed,
|
||||
Count = 0,
|
||||
Score = 0,
|
||||
FirstDocumentPath = document.FilePath,
|
||||
FirstOccurrenceOrder = order,
|
||||
};
|
||||
sentenceMap[trimmed] = entry;
|
||||
}
|
||||
|
||||
entry.Count++;
|
||||
entry.Score = entry.Count;
|
||||
}
|
||||
}
|
||||
|
||||
return CreateCandidatesFromRankedEntries(
|
||||
sentenceMap.Values
|
||||
.OrderByDescending(item => item.Count)
|
||||
.ThenBy(item => item.FirstOccurrenceOrder)
|
||||
.Take(settings.MaxSentenceCount),
|
||||
AutoExtractCategory.HighFrequencySentence,
|
||||
"高频句");
|
||||
}
|
||||
|
||||
private IEnumerable<TokenObservation> BuildTokenObservations(IList<ExtractedDocumentText> documents, AutoExtractSettings settings)
|
||||
{
|
||||
var observations = new List<TokenObservation>();
|
||||
var order = 0;
|
||||
|
||||
for (var documentIndex = 0; documentIndex < documents.Count; documentIndex++)
|
||||
{
|
||||
var document = documents[documentIndex];
|
||||
foreach (Match match in TokenRegex.Matches(document.VisibleText ?? string.Empty))
|
||||
{
|
||||
if (match.Groups["Email"].Success)
|
||||
{
|
||||
if (!settings.ExcludeEmailAddresses)
|
||||
{
|
||||
order++;
|
||||
observations.Add(CreateTokenObservation(match.Value, document.FilePath, documentIndex, order));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.Groups["English"].Success)
|
||||
{
|
||||
if (settings.CountEnglishWords && match.Value.Length >= settings.MinEnglishWordLength)
|
||||
{
|
||||
order++;
|
||||
observations.Add(CreateTokenObservation(match.Value, document.FilePath, documentIndex, order));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.Groups["Chinese"].Success && settings.CountChineseWords)
|
||||
{
|
||||
foreach (var token in SegmentChinese(match.Value, settings.MinChineseWordLength))
|
||||
{
|
||||
if (!ContainsChinese(token))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
order++;
|
||||
observations.Add(CreateTokenObservation(token, document.FilePath, documentIndex, order));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return observations;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SegmentChinese(string text, int minLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var normalized = new string(text.Where(character => character >= 0x4E00 && character <= 0x9FFF).ToArray());
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var effectiveMinLength = Math.Max(minLength, 1);
|
||||
var maxLength = Math.Min(4, normalized.Length);
|
||||
var emitted = false;
|
||||
|
||||
for (var length = maxLength; length >= effectiveMinLength; length--)
|
||||
{
|
||||
for (var index = 0; index + length <= normalized.Length; index++)
|
||||
{
|
||||
emitted = true;
|
||||
yield return normalized.Substring(index, length);
|
||||
}
|
||||
}
|
||||
|
||||
if (!emitted && normalized.Length >= effectiveMinLength)
|
||||
{
|
||||
yield return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<RankedEntry> RankByFrequency(IList<TokenObservation> observations)
|
||||
{
|
||||
return observations
|
||||
.GroupBy(item => item.Token, StringComparer.Ordinal)
|
||||
.Select(group => new RankedEntry
|
||||
{
|
||||
Text = group.Key,
|
||||
Count = group.Count(),
|
||||
Score = group.Count(),
|
||||
FirstDocumentPath = group.OrderBy(item => item.GlobalOrder).First().FilePath,
|
||||
FirstOccurrenceOrder = group.Min(item => item.GlobalOrder),
|
||||
})
|
||||
.OrderByDescending(item => item.Score)
|
||||
.ThenBy(item => item.FirstOccurrenceOrder)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private IEnumerable<RankedEntry> RankByTfIdf(IList<TokenObservation> observations)
|
||||
{
|
||||
var documentCount = observations.Select(item => item.DocumentIndex).Distinct().Count();
|
||||
var byToken = observations.GroupBy(item => item.Token, StringComparer.Ordinal);
|
||||
var ranked = new List<RankedEntry>();
|
||||
|
||||
foreach (var tokenGroup in byToken)
|
||||
{
|
||||
var docFrequency = tokenGroup.Select(item => item.DocumentIndex).Distinct().Count();
|
||||
var termFrequency = tokenGroup.Count();
|
||||
var idf = Math.Log((documentCount + 1.0) / (docFrequency + 1.0)) + 1.0;
|
||||
ranked.Add(new RankedEntry
|
||||
{
|
||||
Text = tokenGroup.Key,
|
||||
Count = termFrequency,
|
||||
Score = termFrequency * idf,
|
||||
FirstDocumentPath = tokenGroup.OrderBy(item => item.GlobalOrder).First().FilePath,
|
||||
FirstOccurrenceOrder = tokenGroup.Min(item => item.GlobalOrder),
|
||||
});
|
||||
}
|
||||
|
||||
return ranked
|
||||
.OrderByDescending(item => item.Score)
|
||||
.ThenBy(item => item.FirstOccurrenceOrder)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private IEnumerable<RankedEntry> RankByTextRank(IList<TokenObservation> observations)
|
||||
{
|
||||
var graph = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
||||
var orderedTokens = observations.OrderBy(item => item.GlobalOrder).ToList();
|
||||
const int windowSize = 4;
|
||||
|
||||
for (var index = 0; index < orderedTokens.Count; index++)
|
||||
{
|
||||
var current = orderedTokens[index].Token;
|
||||
if (!graph.ContainsKey(current))
|
||||
{
|
||||
graph[current] = new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
for (var offset = 1; offset < windowSize && index + offset < orderedTokens.Count; offset++)
|
||||
{
|
||||
var neighbor = orderedTokens[index + offset].Token;
|
||||
if (string.Equals(current, neighbor, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
graph[current].Add(neighbor);
|
||||
if (!graph.ContainsKey(neighbor))
|
||||
{
|
||||
graph[neighbor] = new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
graph[neighbor].Add(current);
|
||||
}
|
||||
}
|
||||
|
||||
var scores = graph.Keys.ToDictionary(key => key, key => 1.0, StringComparer.Ordinal);
|
||||
for (var iteration = 0; iteration < 20; iteration++)
|
||||
{
|
||||
var next = new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
foreach (var node in graph.Keys)
|
||||
{
|
||||
var score = 0.15;
|
||||
foreach (var neighbor in graph[node])
|
||||
{
|
||||
var degree = Math.Max(graph[neighbor].Count, 1);
|
||||
score += 0.85 * (scores[neighbor] / degree);
|
||||
}
|
||||
|
||||
next[node] = score;
|
||||
}
|
||||
|
||||
scores = next;
|
||||
}
|
||||
|
||||
return observations
|
||||
.GroupBy(item => item.Token, StringComparer.Ordinal)
|
||||
.Select(group => new RankedEntry
|
||||
{
|
||||
Text = group.Key,
|
||||
Count = group.Count(),
|
||||
Score = scores.ContainsKey(group.Key) ? scores[group.Key] : 0,
|
||||
FirstDocumentPath = group.OrderBy(item => item.GlobalOrder).First().FilePath,
|
||||
FirstOccurrenceOrder = group.Min(item => item.GlobalOrder),
|
||||
})
|
||||
.OrderByDescending(item => item.Score)
|
||||
.ThenBy(item => item.FirstOccurrenceOrder)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<ExtractionCandidate> CreateCandidatesFromRankedEntries(
|
||||
IEnumerable<RankedEntry> entries,
|
||||
AutoExtractCategory category,
|
||||
string categoryDisplay)
|
||||
{
|
||||
return entries.Select(entry => new ExtractionCandidate
|
||||
{
|
||||
PrimaryCategory = category,
|
||||
CategoryDisplay = categoryDisplay,
|
||||
OriginalText = entry.Text,
|
||||
StatisticDisplay = string.Format("次数={0}; 分值={1:F3}", entry.Count, entry.Score),
|
||||
FirstDocumentPath = entry.FirstDocumentPath,
|
||||
FirstOccurrenceOrder = entry.FirstOccurrenceOrder,
|
||||
});
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitSentences(string text, HashSet<char> delimiters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var buffer = string.Empty;
|
||||
foreach (var character in text)
|
||||
{
|
||||
if (delimiters.Contains(character))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(buffer))
|
||||
{
|
||||
yield return buffer;
|
||||
buffer = string.Empty;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer += character;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(buffer))
|
||||
{
|
||||
yield return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowedSentence(string sentence, AutoExtractSettings settings)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sentence))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasChinese = ContainsChinese(sentence);
|
||||
var hasEnglish = sentence.Any(character => (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z'));
|
||||
|
||||
if (hasChinese && settings.CountChineseSentences)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasEnglish && settings.CountEnglishSentences)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return !hasChinese && !hasEnglish;
|
||||
}
|
||||
|
||||
private static bool ContainsChinese(string value)
|
||||
{
|
||||
return value.Any(character => character >= 0x4E00 && character <= 0x9FFF);
|
||||
}
|
||||
|
||||
private static TokenObservation CreateTokenObservation(string token, string filePath, int documentIndex, int order)
|
||||
{
|
||||
return new TokenObservation
|
||||
{
|
||||
Token = token,
|
||||
FilePath = filePath,
|
||||
DocumentIndex = documentIndex,
|
||||
GlobalOrder = order,
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddCandidate(
|
||||
IDictionary<string, ExtractionCandidate> candidateMap,
|
||||
ISet<string> existingSearchTexts,
|
||||
ExtractionCandidate candidate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate.OriginalText))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingSearchTexts.Contains(candidate.OriginalText))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ExtractionCandidate existing;
|
||||
if (!candidateMap.TryGetValue(candidate.OriginalText, out existing))
|
||||
{
|
||||
candidateMap[candidate.OriginalText] = candidate;
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.CategoryDisplay.IndexOf(candidate.CategoryDisplay, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
existing.CategoryDisplay += ", " + candidate.CategoryDisplay;
|
||||
}
|
||||
}
|
||||
|
||||
private class TokenObservation
|
||||
{
|
||||
public string Token { get; set; }
|
||||
|
||||
public string FilePath { get; set; }
|
||||
|
||||
public int DocumentIndex { get; set; }
|
||||
|
||||
public int GlobalOrder { get; set; }
|
||||
}
|
||||
|
||||
private class RankedEntry
|
||||
{
|
||||
public string Text { get; set; }
|
||||
|
||||
public int Count { get; set; }
|
||||
|
||||
public double Score { get; set; }
|
||||
|
||||
public string FirstDocumentPath { get; set; }
|
||||
|
||||
public int FirstOccurrenceOrder { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
122
DCIT.Core/Services/BatchExecutionPlanner.cs
Normal file
122
DCIT.Core/Services/BatchExecutionPlanner.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using DCIT.Core.Models;
|
||||
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public sealed class BatchExecutionPlanner
|
||||
{
|
||||
public BatchExecutionPlan CreatePlan(IList<FileScanItem> selectedItems, int processorCount)
|
||||
{
|
||||
var executionItems = new List<FileScanItem>();
|
||||
var skippedItems = new List<BatchExecutionSkipItem>();
|
||||
var sourcePaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var outputPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var diffPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (selectedItems != null)
|
||||
{
|
||||
foreach (var item in selectedItems)
|
||||
{
|
||||
if (item == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sourcePath = NormalizePath(item.SourcePath);
|
||||
var outputPath = NormalizePath(item.OutputPath);
|
||||
var diffPath = NormalizePath(item.DiffPath);
|
||||
|
||||
var collision = GetCollision(sourcePath, sourcePaths, BatchExecutionSkipReason.DuplicateSourcePath);
|
||||
if (collision == null)
|
||||
{
|
||||
collision = GetCollision(outputPath, outputPaths, BatchExecutionSkipReason.DuplicateOutputPath);
|
||||
}
|
||||
|
||||
if (collision == null && !string.IsNullOrWhiteSpace(diffPath))
|
||||
{
|
||||
collision = GetCollision(diffPath, diffPaths, BatchExecutionSkipReason.DuplicateDiffPath);
|
||||
}
|
||||
|
||||
if (collision != null)
|
||||
{
|
||||
collision.Item = item;
|
||||
skippedItems.Add(collision);
|
||||
continue;
|
||||
}
|
||||
|
||||
AddIfPresent(sourcePaths, sourcePath);
|
||||
AddIfPresent(outputPaths, outputPath);
|
||||
AddIfPresent(diffPaths, diffPath);
|
||||
executionItems.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
var requiresSerialExecution = executionItems.Any(item => Path.GetExtension(item.SourcePath).Equals(".doc", StringComparison.OrdinalIgnoreCase));
|
||||
var degreeOfParallelism = DetermineDegreeOfParallelism(executionItems.Count, processorCount, requiresSerialExecution);
|
||||
|
||||
return new BatchExecutionPlan
|
||||
{
|
||||
ExecutionItems = executionItems,
|
||||
SkippedItems = skippedItems,
|
||||
DegreeOfParallelism = degreeOfParallelism,
|
||||
RequiresSerialExecution = requiresSerialExecution,
|
||||
};
|
||||
}
|
||||
|
||||
private static BatchExecutionSkipItem GetCollision(string path, HashSet<string> existingPaths, BatchExecutionSkipReason reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !existingPaths.Contains(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BatchExecutionSkipItem
|
||||
{
|
||||
Reason = reason,
|
||||
ConflictingPath = path,
|
||||
ExistingPath = path,
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddIfPresent(HashSet<string> set, string path)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
set.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static int DetermineDegreeOfParallelism(int itemCount, int processorCount, bool requiresSerialExecution)
|
||||
{
|
||||
if (itemCount <= 0 || requiresSerialExecution)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var normalizedProcessorCount = processorCount > 0 ? processorCount : Environment.ProcessorCount;
|
||||
normalizedProcessorCount = Math.Max(1, normalizedProcessorCount);
|
||||
return Math.Max(1, Math.Min(itemCount, normalizedProcessorCount));
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Path.GetFullPath(path)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return path.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
240
DCIT.Core/Services/BmpDiffCodec.cs
Normal file
240
DCIT.Core/Services/BmpDiffCodec.cs
Normal file
@@ -0,0 +1,240 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DCIT.Core.Utilities;
|
||||
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public class BmpDiffCodec
|
||||
{
|
||||
private const string Magic = "DCITBMP1";
|
||||
private const ushort HeaderVersion = 1;
|
||||
private const ushort Flags = 0;
|
||||
private const int FixedWidth = 1024;
|
||||
private const int HeaderSize = 8 + 2 + 2 + 8 + 32;
|
||||
|
||||
public void EncodeJson(string canonicalJson, string outputPath)
|
||||
{
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(canonicalJson ?? string.Empty);
|
||||
using (var stream = new MemoryStream(payloadBytes))
|
||||
{
|
||||
EncodeFromStream(stream, outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
public void EncodeFromStream(Stream jsonStream, string outputPath)
|
||||
{
|
||||
if (jsonStream == null)
|
||||
{
|
||||
throw new ArgumentNullException("jsonStream");
|
||||
}
|
||||
|
||||
byte[] hash;
|
||||
long length;
|
||||
|
||||
if (jsonStream.CanSeek)
|
||||
{
|
||||
length = jsonStream.Length;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
hash = sha.ComputeHash(jsonStream);
|
||||
}
|
||||
|
||||
jsonStream.Position = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
using (var buffered = new MemoryStream())
|
||||
{
|
||||
jsonStream.CopyTo(buffered);
|
||||
length = buffered.Length;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
hash = sha.ComputeHash(buffered);
|
||||
}
|
||||
|
||||
buffered.Position = 0;
|
||||
WriteBitmapWithPayload(outputPath, length, hash, buffered);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
WriteBitmapWithPayload(outputPath, length, hash, jsonStream);
|
||||
}
|
||||
|
||||
public string DecodeJson(string bmpPath)
|
||||
{
|
||||
using (var bitmap = new Bitmap(bmpPath))
|
||||
{
|
||||
var rect = new Rectangle(0, 0, bitmap.Width, bitmap.Height);
|
||||
var bitmapData = bitmap.LockBits(rect, ImageLockMode.ReadOnly, bitmap.PixelFormat);
|
||||
try
|
||||
{
|
||||
var rowLength = bitmap.Width;
|
||||
var pixelBytes = new byte[rowLength * bitmap.Height];
|
||||
for (var row = 0; row < bitmap.Height; row++)
|
||||
{
|
||||
var source = IntPtr.Add(bitmapData.Scan0, row * bitmapData.Stride);
|
||||
Marshal.Copy(source, pixelBytes, row * rowLength, rowLength);
|
||||
}
|
||||
|
||||
var payloadBytes = ParseBlob(pixelBytes);
|
||||
return Encoding.UTF8.GetString(payloadBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
bitmap.UnlockBits(bitmapData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteBitmapWithPayload(string outputPath, long payloadLength, byte[] payloadHash, Stream payloadStream)
|
||||
{
|
||||
var totalBytes = HeaderSize + payloadLength;
|
||||
var height = Math.Max(1, (int)Math.Ceiling(totalBytes / (double)FixedWidth));
|
||||
var pixelCount = FixedWidth * height;
|
||||
var pixelBytes = new byte[pixelCount];
|
||||
|
||||
var offset = 0;
|
||||
var magicBytes = Encoding.ASCII.GetBytes(Magic);
|
||||
Buffer.BlockCopy(magicBytes, 0, pixelBytes, offset, magicBytes.Length);
|
||||
offset += magicBytes.Length;
|
||||
|
||||
pixelBytes[offset++] = (byte)(HeaderVersion & 0xFF);
|
||||
pixelBytes[offset++] = (byte)((HeaderVersion >> 8) & 0xFF);
|
||||
|
||||
pixelBytes[offset++] = (byte)(Flags & 0xFF);
|
||||
pixelBytes[offset++] = (byte)((Flags >> 8) & 0xFF);
|
||||
|
||||
pixelBytes[offset++] = (byte)(payloadLength & 0xFF);
|
||||
pixelBytes[offset++] = (byte)((payloadLength >> 8) & 0xFF);
|
||||
pixelBytes[offset++] = (byte)((payloadLength >> 16) & 0xFF);
|
||||
pixelBytes[offset++] = (byte)((payloadLength >> 24) & 0xFF);
|
||||
pixelBytes[offset++] = (byte)((payloadLength >> 32) & 0xFF);
|
||||
pixelBytes[offset++] = (byte)((payloadLength >> 40) & 0xFF);
|
||||
pixelBytes[offset++] = (byte)((payloadLength >> 48) & 0xFF);
|
||||
pixelBytes[offset++] = (byte)((payloadLength >> 56) & 0xFF);
|
||||
|
||||
Buffer.BlockCopy(payloadHash, 0, pixelBytes, offset, 32);
|
||||
offset += 32;
|
||||
|
||||
var buffer = new byte[65536];
|
||||
int read;
|
||||
while ((read = payloadStream.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
if (offset + read > pixelBytes.Length)
|
||||
{
|
||||
throw new InvalidDataException("BMP 载荷长度超出预期像素缓冲。");
|
||||
}
|
||||
|
||||
Buffer.BlockCopy(buffer, 0, pixelBytes, offset, read);
|
||||
offset += read;
|
||||
}
|
||||
|
||||
if (offset != totalBytes)
|
||||
{
|
||||
throw new InvalidDataException("BMP 载荷长度与声明的长度不一致。");
|
||||
}
|
||||
|
||||
using (var bitmap = new Bitmap(FixedWidth, height, PixelFormat.Format8bppIndexed))
|
||||
{
|
||||
var palette = bitmap.Palette;
|
||||
for (var index = 0; index < 256; index++)
|
||||
{
|
||||
palette.Entries[index] = Color.FromArgb(index, index, index);
|
||||
}
|
||||
|
||||
bitmap.Palette = palette;
|
||||
var rect = new Rectangle(0, 0, bitmap.Width, bitmap.Height);
|
||||
var bitmapData = bitmap.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format8bppIndexed);
|
||||
try
|
||||
{
|
||||
for (var row = 0; row < height; row++)
|
||||
{
|
||||
var sourceOffset = row * FixedWidth;
|
||||
var destination = IntPtr.Add(bitmapData.Scan0, row * bitmapData.Stride);
|
||||
Marshal.Copy(pixelBytes, sourceOffset, destination, FixedWidth);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
bitmap.UnlockBits(bitmapData);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? string.Empty);
|
||||
bitmap.Save(outputPath, ImageFormat.Bmp);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildBlob(byte[] payloadBytes)
|
||||
{
|
||||
payloadBytes = payloadBytes ?? new byte[0];
|
||||
var payloadHash = Convert.FromBase64String(HashUtility.ComputeSha256Base64(payloadBytes));
|
||||
using (var stream = new MemoryStream())
|
||||
using (var writer = new BinaryWriter(stream, Encoding.ASCII))
|
||||
{
|
||||
writer.Write(Encoding.ASCII.GetBytes(Magic));
|
||||
writer.Write(HeaderVersion);
|
||||
writer.Write(Flags);
|
||||
writer.Write((ulong)payloadBytes.Length);
|
||||
writer.Write(payloadHash);
|
||||
writer.Write(payloadBytes);
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ParseBlob(byte[] blobBytes)
|
||||
{
|
||||
using (var stream = new MemoryStream(blobBytes))
|
||||
using (var reader = new BinaryReader(stream, Encoding.ASCII))
|
||||
{
|
||||
var magic = Encoding.ASCII.GetString(reader.ReadBytes(8));
|
||||
if (!string.Equals(magic, Magic, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException("BMP 差异文件魔数无效。");
|
||||
}
|
||||
|
||||
var version = reader.ReadUInt16();
|
||||
if (version != HeaderVersion)
|
||||
{
|
||||
throw new InvalidDataException("BMP 差异文件版本不受支持。");
|
||||
}
|
||||
|
||||
_ = reader.ReadUInt16();
|
||||
var payloadLength = reader.ReadUInt64();
|
||||
var payloadHash = reader.ReadBytes(32);
|
||||
var payloadBytes = reader.ReadBytes((int)payloadLength);
|
||||
var actualHash = Convert.FromBase64String(HashUtility.ComputeSha256Base64(payloadBytes));
|
||||
if (!BytesEqual(payloadHash, actualHash))
|
||||
{
|
||||
throw new InvalidDataException("BMP 差异文件载荷校验失败。");
|
||||
}
|
||||
|
||||
return payloadBytes;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool BytesEqual(byte[] left, byte[] right)
|
||||
{
|
||||
if (left == null || right == null || left.Length != right.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var index = 0; index < left.Length; index++)
|
||||
{
|
||||
if (left[index] != right[index])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
66
DCIT.Core/Services/ConfigurationService.cs
Normal file
66
DCIT.Core/Services/ConfigurationService.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using DCIT.Core.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public class ConfigurationService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
|
||||
public ConfigurationService(string startupDirectory)
|
||||
{
|
||||
_configPath = Path.Combine(startupDirectory, "config.json");
|
||||
}
|
||||
|
||||
public string ConfigPath
|
||||
{
|
||||
get { return _configPath; }
|
||||
}
|
||||
|
||||
public ConfigurationLoadResult Load()
|
||||
{
|
||||
if (!File.Exists(_configPath))
|
||||
{
|
||||
return new ConfigurationLoadResult
|
||||
{
|
||||
Config = CreateDefault(),
|
||||
IsMissing = true,
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(_configPath, Encoding.UTF8);
|
||||
var config = JsonConvert.DeserializeObject<AppConfig>(content) ?? CreateDefault();
|
||||
return new ConfigurationLoadResult
|
||||
{
|
||||
Config = config,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ConfigurationLoadResult
|
||||
{
|
||||
Config = CreateDefault(),
|
||||
IsCorrupted = true,
|
||||
ErrorMessage = ex.Message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(AppConfig config)
|
||||
{
|
||||
config.SavedAt = DateTime.Now;
|
||||
var content = JsonConvert.SerializeObject(config, Formatting.Indented);
|
||||
File.WriteAllText(_configPath, content, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
public AppConfig CreateDefault()
|
||||
{
|
||||
return new AppConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
592
DCIT.Core/Services/DiffSerializationService.cs
Normal file
592
DCIT.Core/Services/DiffSerializationService.cs
Normal file
@@ -0,0 +1,592 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using DCIT.Core.Models;
|
||||
using DCIT.Core.Utilities;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public class DiffSerializationService
|
||||
{
|
||||
private static readonly byte[] PayloadCiphertextPropertyMarker = Encoding.UTF8.GetBytes("\"PayloadCiphertext\":");
|
||||
private static readonly byte[] PayloadCiphertextValueMarker = Encoding.UTF8.GetBytes("\"PayloadCiphertext\":\"");
|
||||
private static readonly byte[] MetadataSyntheticSuffix = Encoding.UTF8.GetBytes("null,\"Payload\":null}");
|
||||
private const int MetadataScanCapBytes = 1 << 20;
|
||||
|
||||
private readonly BmpDiffCodec _bmpDiffCodec;
|
||||
private readonly string _appVersion;
|
||||
|
||||
public DiffSerializationService(BmpDiffCodec bmpDiffCodec)
|
||||
{
|
||||
_bmpDiffCodec = bmpDiffCodec;
|
||||
_appVersion = Assembly.GetExecutingAssembly().GetName().Version != null
|
||||
? Assembly.GetExecutingAssembly().GetName().Version.ToString()
|
||||
: "1.0.0";
|
||||
}
|
||||
|
||||
public string AppVersion
|
||||
{
|
||||
get { return _appVersion; }
|
||||
}
|
||||
|
||||
public string GetBmpPath(string diffPath)
|
||||
{
|
||||
return Path.ChangeExtension(diffPath, ".bmp");
|
||||
}
|
||||
|
||||
public string Write(DiffDocument plainDiff, string diffPath, bool encryptJson)
|
||||
{
|
||||
if (plainDiff == null)
|
||||
{
|
||||
throw new ArgumentNullException("plainDiff");
|
||||
}
|
||||
|
||||
if (plainDiff.Payload == null)
|
||||
{
|
||||
throw new InvalidOperationException("差异载荷不能为空。");
|
||||
}
|
||||
|
||||
var originalPayload = plainDiff.Payload;
|
||||
var originalEncrypted = plainDiff.Encrypted;
|
||||
var originalEncryption = plainDiff.Encryption;
|
||||
var originalPayloadCiphertext = plainDiff.PayloadCiphertext;
|
||||
|
||||
string payloadTmpPath = null;
|
||||
string cipherTmpPath = null;
|
||||
|
||||
try
|
||||
{
|
||||
payloadTmpPath = Path.Combine(Path.GetTempPath(), "dcit-payload-" + Guid.NewGuid().ToString("N") + ".bin");
|
||||
SerializeInstanceToUtf8File(plainDiff.Payload, payloadTmpPath);
|
||||
plainDiff.Integrity.PayloadHash = HashUtility.ComputeFileSha256Base64(payloadTmpPath);
|
||||
plainDiff.Integrity.TamperProtectionValue = HashUtility.ComputeFileHmacBase64(payloadTmpPath);
|
||||
|
||||
plainDiff.Encrypted = false;
|
||||
plainDiff.Encryption = null;
|
||||
plainDiff.PayloadCiphertext = null;
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(diffPath) ?? string.Empty);
|
||||
var bmpPath = GetBmpPath(diffPath);
|
||||
|
||||
// Serialize the full plainDiff (with Payload attached) to BMP. Use a temp file so we
|
||||
// never hold the whole BMP-bound JSON in memory.
|
||||
var bmpTmpPath = Path.Combine(Path.GetTempPath(), "dcit-bmp-" + Guid.NewGuid().ToString("N") + ".json");
|
||||
try
|
||||
{
|
||||
SerializeInstanceToUtf8File(plainDiff, bmpTmpPath);
|
||||
using (var bmpFile = File.OpenRead(bmpTmpPath))
|
||||
{
|
||||
_bmpDiffCodec.EncodeFromStream(bmpFile, bmpPath);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteFile(bmpTmpPath);
|
||||
}
|
||||
|
||||
if (encryptJson)
|
||||
{
|
||||
cipherTmpPath = Path.Combine(Path.GetTempPath(), "dcit-cipher-" + Guid.NewGuid().ToString("N") + ".bin");
|
||||
EncryptedPayloadHeader header;
|
||||
using (var payloadStream = File.OpenRead(payloadTmpPath))
|
||||
using (var cipherStream = File.Create(cipherTmpPath))
|
||||
{
|
||||
header = HashUtility.EncryptStream(payloadStream, cipherStream);
|
||||
}
|
||||
|
||||
plainDiff.Encrypted = true;
|
||||
plainDiff.Encryption = new DiffEncryptionInfo
|
||||
{
|
||||
Algorithm = "AES-256-CBC+HMAC-SHA256",
|
||||
KeyId = "default",
|
||||
Nonce = header.NonceBase64,
|
||||
Tag = header.TagBase64,
|
||||
};
|
||||
plainDiff.PayloadCiphertext = null;
|
||||
plainDiff.Payload = null;
|
||||
|
||||
WriteEncryptedDiffToFile(plainDiff, diffPath, cipherTmpPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
SerializeToFile(plainDiff, diffPath);
|
||||
}
|
||||
|
||||
return bmpPath;
|
||||
}
|
||||
finally
|
||||
{
|
||||
plainDiff.Payload = originalPayload;
|
||||
plainDiff.Encrypted = originalEncrypted;
|
||||
plainDiff.Encryption = originalEncryption;
|
||||
plainDiff.PayloadCiphertext = originalPayloadCiphertext;
|
||||
|
||||
TryDeleteFile(payloadTmpPath);
|
||||
TryDeleteFile(cipherTmpPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SerializeInstanceToUtf8File(object instance, string path)
|
||||
{
|
||||
using (var stream = File.Create(path))
|
||||
using (var streamWriter = new StreamWriter(stream, new UTF8Encoding(false), bufferSize: 65536))
|
||||
using (var jsonWriter = new JsonTextWriter(streamWriter))
|
||||
{
|
||||
var serializer = JsonSerializer.CreateDefault();
|
||||
serializer.Formatting = Formatting.None;
|
||||
serializer.Serialize(jsonWriter, instance);
|
||||
jsonWriter.Flush();
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteEncryptedDiffToFile(DiffDocument document, string diffPath, string cipherTempPath)
|
||||
{
|
||||
var metadataJson = JsonConvert.SerializeObject(document, Formatting.None);
|
||||
const string placeholder = "\"PayloadCiphertext\":null";
|
||||
var placeholderIndex = metadataJson.IndexOf(placeholder, StringComparison.Ordinal);
|
||||
if (placeholderIndex < 0)
|
||||
{
|
||||
throw new InvalidOperationException("加密差异元数据缺少 PayloadCiphertext 占位符。");
|
||||
}
|
||||
|
||||
using (var fileStream = File.Create(diffPath))
|
||||
using (var writer = new StreamWriter(fileStream, new UTF8Encoding(false), bufferSize: 65536))
|
||||
{
|
||||
writer.Write(metadataJson.Substring(0, placeholderIndex));
|
||||
writer.Write("\"PayloadCiphertext\":\"");
|
||||
using (var cipherStream = File.OpenRead(cipherTempPath))
|
||||
{
|
||||
var buffer = new byte[30720];
|
||||
int read;
|
||||
while ((read = cipherStream.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
writer.Write(Convert.ToBase64String(buffer, 0, read));
|
||||
}
|
||||
}
|
||||
|
||||
writer.Write('"');
|
||||
writer.Write(metadataJson.Substring(placeholderIndex + placeholder.Length));
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup; temp files live in the system temp dir.
|
||||
}
|
||||
}
|
||||
|
||||
public LoadedDiffDocument Load(string diffOrBmpPath)
|
||||
{
|
||||
if (diffOrBmpPath == null)
|
||||
{
|
||||
throw new ArgumentNullException("diffOrBmpPath");
|
||||
}
|
||||
|
||||
if (Path.GetExtension(diffOrBmpPath).Equals(".bmp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LoadFromBmp(diffOrBmpPath);
|
||||
}
|
||||
|
||||
return LoadFromDiff(diffOrBmpPath);
|
||||
}
|
||||
|
||||
public DiffDocument ReadMetadata(string diffOrBmpPath)
|
||||
{
|
||||
if (diffOrBmpPath == null)
|
||||
{
|
||||
throw new ArgumentNullException("diffOrBmpPath");
|
||||
}
|
||||
|
||||
if (Path.GetExtension(diffOrBmpPath).Equals(".bmp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var json = _bmpDiffCodec.DecodeJson(diffOrBmpPath);
|
||||
return JsonConvert.DeserializeObject<DiffDocument>(json) ?? new DiffDocument();
|
||||
}
|
||||
|
||||
return ReadDiffMetadata(diffOrBmpPath);
|
||||
}
|
||||
|
||||
public DiffDocument CreateBaseDocument()
|
||||
{
|
||||
return new DiffDocument
|
||||
{
|
||||
App = new DiffAppInfo
|
||||
{
|
||||
Name = "DCIT",
|
||||
Version = _appVersion,
|
||||
},
|
||||
Algorithm = new DiffAlgorithmInfo
|
||||
{
|
||||
DiffVersion = "1.0",
|
||||
BmpCodecVersion = "1.0",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// BMP diffs use a legacy in-memory load path. Large BMP-encoded diffs remain a known
|
||||
// OOM risk; the default and reported path is .diff, which is fully streaming below.
|
||||
private LoadedDiffDocument LoadFromBmp(string bmpPath)
|
||||
{
|
||||
var json = _bmpDiffCodec.DecodeJson(bmpPath);
|
||||
var document = JsonConvert.DeserializeObject<DiffDocument>(json);
|
||||
if (document == null)
|
||||
{
|
||||
throw new InvalidDataException("差异文件解析失败。");
|
||||
}
|
||||
|
||||
if (document.Encrypted)
|
||||
{
|
||||
if (document.Encryption == null || string.IsNullOrWhiteSpace(document.PayloadCiphertext))
|
||||
{
|
||||
throw new InvalidDataException("加密差异文件缺少必要字段。");
|
||||
}
|
||||
|
||||
var payloadBytes = HashUtility.Decrypt(
|
||||
document.PayloadCiphertext,
|
||||
document.Encryption.Nonce,
|
||||
document.Encryption.Tag);
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
document.Payload = JsonConvert.DeserializeObject<DiffPayload>(payloadJson);
|
||||
ValidateIntegrity(document, payloadBytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (document.Payload == null)
|
||||
{
|
||||
throw new InvalidDataException("明文差异文件缺少 payload。");
|
||||
}
|
||||
|
||||
ValidateIntegrity(document, Encoding.UTF8.GetBytes(SerializeCanonical(document.Payload)));
|
||||
}
|
||||
|
||||
return new LoadedDiffDocument
|
||||
{
|
||||
Document = document,
|
||||
};
|
||||
}
|
||||
|
||||
private LoadedDiffDocument LoadFromDiff(string diffPath)
|
||||
{
|
||||
var document = ReadDiffMetadata(diffPath);
|
||||
string payloadTmpPath = null;
|
||||
string cipherTmpPath = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (document.Encrypted)
|
||||
{
|
||||
if (document.Encryption == null
|
||||
|| string.IsNullOrWhiteSpace(document.Encryption.Nonce)
|
||||
|| string.IsNullOrWhiteSpace(document.Encryption.Tag))
|
||||
{
|
||||
throw new InvalidDataException("加密差异文件缺少加密元数据。");
|
||||
}
|
||||
|
||||
cipherTmpPath = Path.Combine(Path.GetTempPath(), "dcit-rcipher-" + Guid.NewGuid().ToString("N") + ".bin");
|
||||
payloadTmpPath = Path.Combine(Path.GetTempPath(), "dcit-rpayload-" + Guid.NewGuid().ToString("N") + ".bin");
|
||||
|
||||
ExtractBase64RegionToFile(diffPath, PayloadCiphertextValueMarker, cipherTmpPath);
|
||||
|
||||
var iv = Convert.FromBase64String(document.Encryption.Nonce);
|
||||
var tag = Convert.FromBase64String(document.Encryption.Tag);
|
||||
using (var cipherStream = File.OpenRead(cipherTmpPath))
|
||||
using (var payloadStream = File.Create(payloadTmpPath))
|
||||
{
|
||||
HashUtility.DecryptStream(cipherStream, payloadStream, iv, tag);
|
||||
}
|
||||
|
||||
ValidateIntegrityFromPayloadFile(document, payloadTmpPath);
|
||||
document.Payload = DeserializePayloadFromFile(payloadTmpPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Stream-deserialize the whole document. PayloadCiphertext is null (small) and
|
||||
// Payload is a large nested object that Newtonsoft builds incrementally without
|
||||
// buffering the whole JSON text or any single huge string token.
|
||||
var full = StreamDeserializeDiffDocument(diffPath);
|
||||
if (full == null)
|
||||
{
|
||||
throw new InvalidDataException("差异文件解析失败。");
|
||||
}
|
||||
|
||||
if (full.Payload == null)
|
||||
{
|
||||
throw new InvalidDataException("明文差异文件缺少 payload。");
|
||||
}
|
||||
|
||||
document = full;
|
||||
payloadTmpPath = Path.Combine(Path.GetTempPath(), "dcit-rpayload-" + Guid.NewGuid().ToString("N") + ".bin");
|
||||
SerializeInstanceToUtf8File(document.Payload, payloadTmpPath);
|
||||
ValidateIntegrityFromPayloadFile(document, payloadTmpPath);
|
||||
}
|
||||
|
||||
return new LoadedDiffDocument
|
||||
{
|
||||
Document = document,
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteFile(payloadTmpPath);
|
||||
TryDeleteFile(cipherTmpPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Reads only the metadata portion of a .diff file. The huge fields (PayloadCiphertext value
|
||||
// and Payload value) sit after the metadata in declaration order; we byte-scan up to the
|
||||
// "PayloadCiphertext" property marker (a few KB), synthesize null values for the two huge
|
||||
// fields, and parse the resulting small metadata JSON. The huge content is never read.
|
||||
private DiffDocument ReadDiffMetadata(string diffPath)
|
||||
{
|
||||
var prefix = ReadPrefixThroughMarker(diffPath, PayloadCiphertextPropertyMarker, MetadataScanCapBytes);
|
||||
string metadataJson;
|
||||
if (prefix == null)
|
||||
{
|
||||
metadataJson = File.ReadAllText(diffPath, Encoding.UTF8);
|
||||
}
|
||||
else
|
||||
{
|
||||
var combined = new byte[prefix.Length + MetadataSyntheticSuffix.Length];
|
||||
Buffer.BlockCopy(prefix, 0, combined, 0, prefix.Length);
|
||||
Buffer.BlockCopy(MetadataSyntheticSuffix, 0, combined, prefix.Length, MetadataSyntheticSuffix.Length);
|
||||
metadataJson = Encoding.UTF8.GetString(combined);
|
||||
}
|
||||
|
||||
return JsonConvert.DeserializeObject<DiffDocument>(metadataJson) ?? new DiffDocument();
|
||||
}
|
||||
|
||||
private static DiffDocument StreamDeserializeDiffDocument(string diffPath)
|
||||
{
|
||||
using (var stream = File.OpenRead(diffPath))
|
||||
using (var streamReader = new StreamReader(stream, new UTF8Encoding(false)))
|
||||
using (var jsonReader = new JsonTextReader(streamReader))
|
||||
{
|
||||
return JsonSerializer.CreateDefault().Deserialize<DiffDocument>(jsonReader);
|
||||
}
|
||||
}
|
||||
|
||||
private static DiffPayload DeserializePayloadFromFile(string payloadPath)
|
||||
{
|
||||
using (var stream = File.OpenRead(payloadPath))
|
||||
using (var streamReader = new StreamReader(stream, new UTF8Encoding(false)))
|
||||
using (var jsonReader = new JsonTextReader(streamReader))
|
||||
{
|
||||
return JsonSerializer.CreateDefault().Deserialize<DiffPayload>(jsonReader);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateIntegrity(DiffDocument document, byte[] payloadBytes)
|
||||
{
|
||||
var actualHash = HashUtility.ComputeSha256Base64(payloadBytes);
|
||||
var actualTamper = HashUtility.ComputeHmacBase64(payloadBytes);
|
||||
if (!string.Equals(actualHash, document.Integrity == null ? null : document.Integrity.PayloadHash, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException("差异文件载荷哈希校验失败。");
|
||||
}
|
||||
|
||||
if (!string.Equals(actualTamper, document.Integrity == null ? null : document.Integrity.TamperProtectionValue, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException("差异文件防篡改校验失败。");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateIntegrityFromPayloadFile(DiffDocument document, string payloadPath)
|
||||
{
|
||||
var actualHash = HashUtility.ComputeFileSha256Base64(payloadPath);
|
||||
var actualTamper = HashUtility.ComputeFileHmacBase64(payloadPath);
|
||||
if (!string.Equals(actualHash, document.Integrity == null ? null : document.Integrity.PayloadHash, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException("差异文件载荷哈希校验失败。");
|
||||
}
|
||||
|
||||
if (!string.Equals(actualTamper, document.Integrity == null ? null : document.Integrity.TamperProtectionValue, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException("差异文件防篡改校验失败。");
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeCanonical(object instance)
|
||||
{
|
||||
return JsonConvert.SerializeObject(instance, Formatting.None);
|
||||
}
|
||||
|
||||
private static void SerializeToFile(object instance, string path)
|
||||
{
|
||||
using (var stream = File.Create(path))
|
||||
using (var streamWriter = new StreamWriter(stream, new UTF8Encoding(false), bufferSize: 65536))
|
||||
using (var jsonWriter = new JsonTextWriter(streamWriter))
|
||||
{
|
||||
var serializer = JsonSerializer.CreateDefault();
|
||||
serializer.Formatting = Formatting.None;
|
||||
serializer.Serialize(jsonWriter, instance);
|
||||
jsonWriter.Flush();
|
||||
}
|
||||
}
|
||||
|
||||
// Reads bytes from the start of the file up to and including the first occurrence of marker.
|
||||
// Returns null if the marker is not found within cap bytes. Caller chooses cap so the marker
|
||||
// (which sits after the small metadata block) is found without reading huge payload content.
|
||||
private static byte[] ReadPrefixThroughMarker(string path, byte[] marker, int cap)
|
||||
{
|
||||
using (var stream = File.OpenRead(path))
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
var buffer = new byte[65536];
|
||||
int read;
|
||||
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
ms.Write(buffer, 0, read);
|
||||
if (ms.Length >= cap)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var arr = ms.ToArray();
|
||||
var idx = IndexOfSubarray(arr, marker);
|
||||
if (idx < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new byte[idx + marker.Length];
|
||||
Buffer.BlockCopy(arr, 0, result, 0, result.Length);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Locates valueMarker in the file, then stream-decodes the base64 region between the marker
|
||||
// and the next closing double-quote into outputPath. Decodes in 4096-char chunks (a multiple
|
||||
// of 4) so there is no mid-chunk base64 alignment error.
|
||||
private static void ExtractBase64RegionToFile(string diffPath, byte[] valueMarker, string outputPath)
|
||||
{
|
||||
long markerEndPosition;
|
||||
using (var probe = File.OpenRead(diffPath))
|
||||
{
|
||||
markerEndPosition = FindMarkerEnd(probe, valueMarker);
|
||||
}
|
||||
|
||||
if (markerEndPosition < 0)
|
||||
{
|
||||
throw new InvalidDataException("差异文件缺少密文字段标记。");
|
||||
}
|
||||
|
||||
using (var stream = File.OpenRead(diffPath))
|
||||
{
|
||||
stream.Position = markerEndPosition;
|
||||
using (var outStream = File.Create(outputPath))
|
||||
{
|
||||
var charBuf = new char[4096];
|
||||
var charLen = 0;
|
||||
var byteBuf = new byte[65536];
|
||||
int read;
|
||||
var done = false;
|
||||
while (!done && (read = stream.Read(byteBuf, 0, byteBuf.Length)) > 0)
|
||||
{
|
||||
for (var i = 0; i < read; i++)
|
||||
{
|
||||
var b = byteBuf[i];
|
||||
if (b == '"')
|
||||
{
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
|
||||
charBuf[charLen++] = (char)b;
|
||||
if (charLen == charBuf.Length)
|
||||
{
|
||||
var decoded = Convert.FromBase64CharArray(charBuf, 0, charLen);
|
||||
outStream.Write(decoded, 0, decoded.Length);
|
||||
charLen = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (charLen > 0)
|
||||
{
|
||||
var decoded = Convert.FromBase64CharArray(charBuf, 0, charLen);
|
||||
outStream.Write(decoded, 0, decoded.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long FindMarkerEnd(Stream stream, byte[] marker)
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
var buffer = new byte[65536];
|
||||
int read;
|
||||
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
ms.Write(buffer, 0, read);
|
||||
var arr = ms.ToArray();
|
||||
var idx = IndexOfSubarray(arr, marker);
|
||||
if (idx >= 0)
|
||||
{
|
||||
return idx + marker.Length;
|
||||
}
|
||||
|
||||
if (ms.Length > MetadataScanCapBytes)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int IndexOfSubarray(byte[] haystack, byte[] needle)
|
||||
{
|
||||
if (needle == null || needle.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (haystack == null || haystack.Length < needle.Length)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (var i = 0; i <= haystack.Length - needle.Length; i++)
|
||||
{
|
||||
var match = true;
|
||||
for (var j = 0; j < needle.Length; j++)
|
||||
{
|
||||
if (haystack[i + j] != needle[j])
|
||||
{
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public class LoadedDiffDocument
|
||||
{
|
||||
public DiffDocument Document { get; set; }
|
||||
}
|
||||
}
|
||||
4434
DCIT.Core/Services/DocumentProcessingService.cs
Normal file
4434
DCIT.Core/Services/DocumentProcessingService.cs
Normal file
File diff suppressed because it is too large
Load Diff
341
DCIT.Core/Services/FileScanService.cs
Normal file
341
DCIT.Core/Services/FileScanService.cs
Normal file
@@ -0,0 +1,341 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using DCIT.Core.Models;
|
||||
using DCIT.Core.Utilities;
|
||||
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public class FileScanService
|
||||
{
|
||||
private static readonly TimeSpan RuleMatchTimeout = TimeSpan.FromSeconds(1);
|
||||
private readonly DiffSerializationService _diffSerializationService;
|
||||
|
||||
public FileScanService()
|
||||
: this(new DiffSerializationService(new BmpDiffCodec()))
|
||||
{
|
||||
}
|
||||
|
||||
public FileScanService(DiffSerializationService diffSerializationService)
|
||||
{
|
||||
if (diffSerializationService == null)
|
||||
{
|
||||
throw new ArgumentNullException("diffSerializationService");
|
||||
}
|
||||
|
||||
_diffSerializationService = diffSerializationService;
|
||||
}
|
||||
|
||||
public IList<FileScanItem> ScanReplaceCandidates(string sourceDirectory, string outputDirectory)
|
||||
{
|
||||
return ScanReplaceCandidates(sourceDirectory, outputDirectory, null);
|
||||
}
|
||||
|
||||
public IList<FileScanItem> ScanReplaceCandidates(string sourceDirectory, string outputDirectory, RuleFile ruleFile)
|
||||
{
|
||||
var items = new List<FileScanItem>();
|
||||
var hasOutputDirectory = !string.IsNullOrWhiteSpace(outputDirectory);
|
||||
var rules = GetEnabledRules(ruleFile, RuleTarget.FileName);
|
||||
var files = EnumerateWordFiles(sourceDirectory);
|
||||
foreach (var file in files)
|
||||
{
|
||||
var relativePath = PathUtility.GetRelativePath(sourceDirectory, file);
|
||||
var relativeDirectory = Path.GetDirectoryName(relativePath) ?? string.Empty;
|
||||
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file);
|
||||
var extension = Path.GetExtension(file);
|
||||
var replacedFileNameWithoutExtension = ApplyRulesToFileName(fileNameWithoutExtension, rules);
|
||||
ValidateReplacementFileName(file, replacedFileNameWithoutExtension);
|
||||
|
||||
var datedFileName = string.Format("{0}-{1:yyyyMMdd}{2}", replacedFileNameWithoutExtension, DateTime.Today, extension);
|
||||
var outputPath = hasOutputDirectory
|
||||
? Path.Combine(outputDirectory, relativeDirectory, datedFileName)
|
||||
: string.Empty;
|
||||
var diffPath = hasOutputDirectory
|
||||
? Path.Combine(outputDirectory, relativeDirectory, string.Format("{0}-{1:yyyyMMdd}.diff", replacedFileNameWithoutExtension, DateTime.Today))
|
||||
: string.Empty;
|
||||
|
||||
items.Add(new FileScanItem
|
||||
{
|
||||
IsSelected = true,
|
||||
SourcePath = file,
|
||||
OutputPath = outputPath,
|
||||
DiffPath = diffPath,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public IList<FileScanItem> ScanRestoreCandidates(string replaceDirectory, string restoreDirectory, string diffInputFormat)
|
||||
{
|
||||
var hasRestoreDirectory = !string.IsNullOrWhiteSpace(restoreDirectory);
|
||||
var normalizedFormat = string.Equals(diffInputFormat, ".bmp", StringComparison.OrdinalIgnoreCase)
|
||||
? ".bmp"
|
||||
: ".diff";
|
||||
var selectedDiffs = Directory.Exists(replaceDirectory)
|
||||
? Directory.EnumerateFiles(replaceDirectory, "*" + normalizedFormat, SearchOption.AllDirectories).ToList()
|
||||
: new List<string>();
|
||||
var diffLookup = selectedDiffs.ToDictionary(
|
||||
path => BuildRestoreKey(replaceDirectory, path),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var wordFiles = EnumerateWordFiles(replaceDirectory);
|
||||
var items = new List<FileScanItem>();
|
||||
foreach (var wordFile in wordFiles)
|
||||
{
|
||||
var key = BuildRestoreKey(replaceDirectory, wordFile);
|
||||
if (!diffLookup.ContainsKey(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relativePath = PathUtility.GetRelativePath(replaceDirectory, wordFile);
|
||||
var relativeDirectory = Path.GetDirectoryName(relativePath) ?? string.Empty;
|
||||
var extension = Path.GetExtension(wordFile);
|
||||
var restoreFileName = ResolveRestoreFileName(diffLookup[key], wordFile);
|
||||
var restorePath = hasRestoreDirectory
|
||||
? Path.Combine(restoreDirectory, relativeDirectory, restoreFileName ?? Path.GetFileNameWithoutExtension(wordFile) + "-rcv" + extension)
|
||||
: string.Empty;
|
||||
|
||||
items.Add(new FileScanItem
|
||||
{
|
||||
IsSelected = true,
|
||||
SourcePath = wordFile,
|
||||
OutputPath = restorePath,
|
||||
DiffPath = diffLookup[key],
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public IList<string> ValidateReplaceDirectories(string sourceDirectory, string outputDirectory)
|
||||
{
|
||||
return PathUtility.ValidateDirectoryPair("原始文件夹", sourceDirectory, "替换文件夹", outputDirectory).ToList();
|
||||
}
|
||||
|
||||
public IList<string> ValidateRestoreDirectories(string replaceDirectory, string restoreDirectory)
|
||||
{
|
||||
return PathUtility.ValidateDirectoryPair("替换文件夹", replaceDirectory, "恢复文件夹", restoreDirectory).ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateWordFiles(string rootDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
|
||||
{
|
||||
return Enumerable.Empty<string>();
|
||||
}
|
||||
|
||||
return Directory.EnumerateFiles(rootDirectory, "*.*", SearchOption.AllDirectories)
|
||||
.Where(path =>
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (fileName != null && fileName.StartsWith("~$", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(path);
|
||||
return extension.Equals(".doc", StringComparison.OrdinalIgnoreCase)
|
||||
|| extension.Equals(".docx", StringComparison.OrdinalIgnoreCase);
|
||||
})
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private string ResolveRestoreFileName(string diffPath, string wordFile)
|
||||
{
|
||||
try
|
||||
{
|
||||
var document = _diffSerializationService.ReadMetadata(diffPath);
|
||||
var sourceFileName = document == null || document.SourceDocument == null
|
||||
? null
|
||||
: document.SourceDocument.FileName;
|
||||
if (!string.IsNullOrWhiteSpace(sourceFileName))
|
||||
{
|
||||
var safeFileName = Path.GetFileName(sourceFileName);
|
||||
if (!string.IsNullOrWhiteSpace(safeFileName)
|
||||
&& string.Equals(safeFileName, sourceFileName, StringComparison.Ordinal)
|
||||
&& !ContainsInvalidFileNameCharacter(safeFileName))
|
||||
{
|
||||
return safeFileName;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Legacy or placeholder diff files are still paired by basename and use the old preview naming.
|
||||
}
|
||||
|
||||
return Path.GetFileNameWithoutExtension(wordFile) + "-rcv" + Path.GetExtension(wordFile);
|
||||
}
|
||||
|
||||
private static IList<RuleDefinition> GetEnabledRules(RuleFile ruleFile, RuleTarget target)
|
||||
{
|
||||
if (ruleFile == null || ruleFile.Rules == null)
|
||||
{
|
||||
return new List<RuleDefinition>();
|
||||
}
|
||||
|
||||
return ruleFile.Rules
|
||||
.Where(rule => rule != null && rule.IsEnabled && !string.IsNullOrEmpty(rule.SearchText) && rule.Target == target)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static string ApplyRulesToFileName(string fileNameWithoutExtension, IList<RuleDefinition> rules)
|
||||
{
|
||||
var result = fileNameWithoutExtension ?? string.Empty;
|
||||
if (rules == null || rules.Count == 0 || string.IsNullOrEmpty(result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
result = ApplyRuleToFileName(result, rule);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ApplyRuleToFileName(string text, RuleDefinition rule)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || rule == null || string.IsNullOrEmpty(rule.SearchText))
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
if (rule.MatchMode == RuleMatchMode.RegularExpression)
|
||||
{
|
||||
var regex = new Regex(rule.SearchText, GetRegexOptions(rule), RuleMatchTimeout);
|
||||
return regex.Replace(text, match =>
|
||||
{
|
||||
if (!match.Success || match.Length <= 0)
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
|
||||
return match.Result(rule.ReplacementText ?? string.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
var escapedPattern = Regex.Escape(rule.SearchText);
|
||||
var plainRegex = new Regex(escapedPattern, GetRegexOptions(rule), RuleMatchTimeout);
|
||||
return plainRegex.Replace(text, match => rule.ReplacementText ?? string.Empty);
|
||||
}
|
||||
|
||||
private static string ApplyRuleToText(string text, RuleDefinition rule)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || rule == null || string.IsNullOrEmpty(rule.SearchText))
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
if (rule.MatchMode == RuleMatchMode.RegularExpression)
|
||||
{
|
||||
var regex = new Regex(rule.SearchText, GetRegexOptions(rule), RuleMatchTimeout);
|
||||
return regex.Replace(text, match =>
|
||||
{
|
||||
if (!match.Success || match.Length <= 0)
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
|
||||
if (rule.IsWholeWord && !IsWholeWord(text, match.Index, match.Length))
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
|
||||
return match.Result(rule.ReplacementText ?? string.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
var escapedPattern = Regex.Escape(rule.SearchText);
|
||||
var plainRegex = new Regex(escapedPattern, GetRegexOptions(rule), RuleMatchTimeout);
|
||||
return plainRegex.Replace(text, match =>
|
||||
{
|
||||
if (rule.IsWholeWord && !IsWholeWord(text, match.Index, match.Length))
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
|
||||
return rule.ReplacementText ?? string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
private static RegexOptions GetRegexOptions(RuleDefinition rule)
|
||||
{
|
||||
var options = RegexOptions.CultureInvariant;
|
||||
if (!rule.IsCaseSensitive)
|
||||
{
|
||||
options |= RegexOptions.IgnoreCase;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private static bool IsWholeWord(string text, int start, int length)
|
||||
{
|
||||
var leftOk = start <= 0 || GetWordCharClass(text[start - 1]) != GetWordCharClass(text[start]);
|
||||
var rightIndex = start + length;
|
||||
var rightOk = rightIndex >= text.Length || 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 void ValidateReplacementFileName(string sourcePath, string fileNameWithoutExtension)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileNameWithoutExtension))
|
||||
{
|
||||
throw new InvalidDataException("文件名规则替换结果为空,无法生成输出文件名。源文件: " + sourcePath);
|
||||
}
|
||||
|
||||
if (ContainsInvalidFileNameCharacter(fileNameWithoutExtension))
|
||||
{
|
||||
throw new InvalidDataException("文件名规则替换结果包含非法文件名字符,无法生成输出文件名。源文件: "
|
||||
+ sourcePath
|
||||
+ ";替换后文件名: "
|
||||
+ fileNameWithoutExtension);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ContainsInvalidFileNameCharacter(string fileName)
|
||||
{
|
||||
if (fileName == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0
|
||||
|| fileName.IndexOf(Path.DirectorySeparatorChar) >= 0
|
||||
|| fileName.IndexOf(Path.AltDirectorySeparatorChar) >= 0;
|
||||
}
|
||||
|
||||
private static string BuildRestoreKey(string rootDirectory, string path)
|
||||
{
|
||||
var relativePath = PathUtility.GetRelativePath(rootDirectory, path);
|
||||
var directory = Path.GetDirectoryName(relativePath) ?? string.Empty;
|
||||
var fileName = Path.GetFileNameWithoutExtension(relativePath);
|
||||
return Path.Combine(directory, fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
908
DCIT.Core/Services/FontValidationService.cs
Normal file
908
DCIT.Core/Services/FontValidationService.cs
Normal file
@@ -0,0 +1,908 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing.Text;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using W = DocumentFormat.OpenXml.Wordprocessing;
|
||||
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public sealed class FontValidationService : IFontValidationService
|
||||
{
|
||||
private const string WordprocessingNamespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
private const string DefaultEastAsiaFallbackFont = "宋体";
|
||||
private const string DefaultLatinFallbackFont = "Times New Roman";
|
||||
|
||||
private static readonly string[] ThemeFontTokens =
|
||||
{
|
||||
"majorAscii",
|
||||
"majorHAnsi",
|
||||
"majorEastAsia",
|
||||
"majorBidi",
|
||||
"minorAscii",
|
||||
"minorHAnsi",
|
||||
"minorEastAsia",
|
||||
"minorBidi",
|
||||
};
|
||||
|
||||
private static readonly string[][] KnownFontAliasGroups =
|
||||
{
|
||||
new[] { "宋体", "SimSun" },
|
||||
new[] { "新宋体", "NSimSun" },
|
||||
new[] { "黑体", "SimHei" },
|
||||
new[] { "仿宋", "FangSong" },
|
||||
new[] { "楷体", "KaiTi" },
|
||||
new[] { "微软雅黑", "Microsoft YaHei" },
|
||||
new[] { "微软雅黑 Light", "Microsoft YaHei Light" },
|
||||
new[] { "微软雅黑 Bold", "Microsoft YaHei Bold" },
|
||||
new[] { "等线", "DengXian" },
|
||||
new[] { "等线 Light", "DengXian Light" },
|
||||
new[] { "等线 Bold", "DengXian Bold" },
|
||||
new[] { "华文宋体", "STSong" },
|
||||
new[] { "华文黑体", "STHeiti" },
|
||||
new[] { "华文中宋", "STZhongsong" },
|
||||
new[] { "华文仿宋", "STFangsong" },
|
||||
new[] { "华文楷体", "STKaiti" },
|
||||
new[] { "幼圆", "YouYuan" },
|
||||
new[] { "隶书", "LiSu" },
|
||||
};
|
||||
|
||||
private static readonly Lazy<Dictionary<string, HashSet<string>>> KnownFontAliases =
|
||||
new Lazy<Dictionary<string, HashSet<string>>>(BuildKnownFontAliases);
|
||||
|
||||
private readonly Lazy<HashSet<string>> _installedFonts;
|
||||
|
||||
public FontValidationService()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public FontValidationService(IEnumerable<string> installedFonts)
|
||||
{
|
||||
_installedFonts = new Lazy<HashSet<string>>(
|
||||
delegate
|
||||
{
|
||||
return installedFonts == null
|
||||
? LoadInstalledFonts()
|
||||
: new HashSet<string>(
|
||||
installedFonts
|
||||
.Select(NormalizeFontName)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
});
|
||||
}
|
||||
|
||||
public FontValidationResult Validate(string docxPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(docxPath))
|
||||
{
|
||||
throw new ArgumentException("docxPath");
|
||||
}
|
||||
|
||||
if (!File.Exists(docxPath))
|
||||
{
|
||||
throw new FileNotFoundException("未找到待校验的文档。", docxPath);
|
||||
}
|
||||
|
||||
using (var document = WordprocessingDocument.Open(docxPath, false))
|
||||
{
|
||||
var themeFonts = BuildThemeFontMap(document);
|
||||
var styleIndex = BuildStyleIndex(document);
|
||||
var defaultFonts = ReadDocumentDefaultFonts(document, themeFonts);
|
||||
var documentFontAliases = BuildDocumentFontAliasMap(document);
|
||||
var usedFonts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var root in EnumerateRelevantRoots(document))
|
||||
{
|
||||
CollectWordRunFonts(root, styleIndex, defaultFonts, themeFonts, usedFonts);
|
||||
CollectDrawingFonts(root, usedFonts);
|
||||
}
|
||||
|
||||
var missingFonts = usedFonts
|
||||
.Select(NormalizeFontName)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Where(name => !IsFontAvailable(name, documentFontAliases))
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new FontValidationResult(
|
||||
usedFonts.OrderBy(name => name, StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
missingFonts);
|
||||
}
|
||||
}
|
||||
|
||||
public FontValidationResult EnsureFontsAvailable(string docxPath)
|
||||
{
|
||||
var result = Validate(docxPath);
|
||||
if (result.MissingFonts.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
ApplyMissingFontFallbacks(docxPath, result.MissingFonts);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ApplyMissingFontFallbacks(string docxPath, IEnumerable<string> missingFonts)
|
||||
{
|
||||
var missingFontSet = new HashSet<string>(
|
||||
(missingFonts ?? Array.Empty<string>())
|
||||
.Select(NormalizeFontName)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
if (missingFontSet.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (var document = WordprocessingDocument.Open(docxPath, true))
|
||||
{
|
||||
var themeFonts = BuildThemeFontMap(document);
|
||||
foreach (var root in EnumerateFontFallbackRoots(document))
|
||||
{
|
||||
var changed = false;
|
||||
foreach (var runFonts in root.Descendants<W.RunFonts>())
|
||||
{
|
||||
changed |= ApplyWordRunFontFallbacks(runFonts, missingFontSet, themeFonts);
|
||||
}
|
||||
|
||||
foreach (var element in root.Descendants())
|
||||
{
|
||||
changed |= ApplyDrawingFontFallback(element, missingFontSet);
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
root.Save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<OpenXmlPartRootElement> EnumerateFontFallbackRoots(WordprocessingDocument document)
|
||||
{
|
||||
var main = document.MainDocumentPart;
|
||||
if (main == null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (main.Document != null)
|
||||
{
|
||||
yield return main.Document;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
yield return part.RootElement;
|
||||
}
|
||||
|
||||
queue.Enqueue(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ApplyWordRunFontFallbacks(
|
||||
W.RunFonts runFonts,
|
||||
ISet<string> missingFonts,
|
||||
IDictionary<string, string> themeFonts)
|
||||
{
|
||||
var changed = false;
|
||||
changed |= ApplyWordFontFallback(
|
||||
runFonts,
|
||||
"ascii",
|
||||
new[] { "asciiTheme" },
|
||||
DefaultLatinFallbackFont,
|
||||
missingFonts,
|
||||
themeFonts);
|
||||
changed |= ApplyWordFontFallback(
|
||||
runFonts,
|
||||
"hAnsi",
|
||||
new[] { "hAnsiTheme" },
|
||||
DefaultLatinFallbackFont,
|
||||
missingFonts,
|
||||
themeFonts);
|
||||
changed |= ApplyWordFontFallback(
|
||||
runFonts,
|
||||
"eastAsia",
|
||||
new[] { "eastAsiaTheme" },
|
||||
DefaultEastAsiaFallbackFont,
|
||||
missingFonts,
|
||||
themeFonts);
|
||||
changed |= ApplyWordFontFallback(
|
||||
runFonts,
|
||||
"cs",
|
||||
new[] { "csTheme", "cstheme" },
|
||||
DefaultLatinFallbackFont,
|
||||
missingFonts,
|
||||
themeFonts);
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static bool ApplyWordFontFallback(
|
||||
OpenXmlElement element,
|
||||
string directLocalName,
|
||||
IEnumerable<string> themeLocalNames,
|
||||
string fallbackFont,
|
||||
ISet<string> missingFonts,
|
||||
IDictionary<string, string> themeFonts)
|
||||
{
|
||||
var directFont = NormalizeFontName(GetAttributeValue(element, directLocalName));
|
||||
var resolvedFont = directFont;
|
||||
if (string.IsNullOrWhiteSpace(resolvedFont))
|
||||
{
|
||||
foreach (var themeLocalName in themeLocalNames)
|
||||
{
|
||||
resolvedFont = ResolveFontName(null, GetAttributeValue(element, themeLocalName), themeFonts);
|
||||
if (!string.IsNullOrWhiteSpace(resolvedFont))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolvedFont) || !missingFonts.Contains(resolvedFont))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
if (!string.Equals(directFont, fallbackFont, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SetWordprocessingAttribute(element, directLocalName, fallbackFont);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed |= RemoveAttributesByLocalName(element, themeLocalNames);
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static bool ApplyDrawingFontFallback(OpenXmlElement element, ISet<string> missingFonts)
|
||||
{
|
||||
if (!(string.Equals(element.LocalName, "latin", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(element.LocalName, "ea", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(element.LocalName, "cs", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(element.LocalName, "font", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var typeface = NormalizeFontName(GetAttributeValue(element, "typeface"));
|
||||
if (string.IsNullOrWhiteSpace(typeface) || !missingFonts.Contains(typeface))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fallbackFont = ResolveDrawingFallbackFont(element);
|
||||
if (string.Equals(typeface, fallbackFont, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
element.SetAttribute(new OpenXmlAttribute(string.Empty, "typeface", string.Empty, fallbackFont));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ResolveDrawingFallbackFont(OpenXmlElement element)
|
||||
{
|
||||
if (string.Equals(element.LocalName, "ea", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DefaultEastAsiaFallbackFont;
|
||||
}
|
||||
|
||||
if (string.Equals(element.LocalName, "font", StringComparison.OrdinalIgnoreCase)
|
||||
&& IsEastAsiaScript(GetAttributeValue(element, "script")))
|
||||
{
|
||||
return DefaultEastAsiaFallbackFont;
|
||||
}
|
||||
|
||||
return DefaultLatinFallbackFont;
|
||||
}
|
||||
|
||||
private static bool IsEastAsiaScript(string script)
|
||||
{
|
||||
var normalized = NormalizeFontName(script);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalized.IndexOf("Hans", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| normalized.IndexOf("Hant", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| normalized.IndexOf("Jpan", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| normalized.IndexOf("Hang", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| normalized.IndexOf("Kore", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| normalized.IndexOf("Bopo", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static void SetWordprocessingAttribute(OpenXmlElement element, string localName, string value)
|
||||
{
|
||||
element.SetAttribute(new OpenXmlAttribute("w", localName, WordprocessingNamespace, value));
|
||||
}
|
||||
|
||||
private static bool RemoveAttributesByLocalName(OpenXmlElement element, IEnumerable<string> localNames)
|
||||
{
|
||||
var names = new HashSet<string>(
|
||||
(localNames ?? Array.Empty<string>()).Where(name => !string.IsNullOrWhiteSpace(name)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
if (names.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
foreach (var attribute in element.GetAttributes().Where(attribute => names.Contains(attribute.LocalName)).ToList())
|
||||
{
|
||||
element.RemoveAttribute(attribute.LocalName, attribute.NamespaceUri);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static HashSet<string> LoadInstalledFonts()
|
||||
{
|
||||
var collection = new InstalledFontCollection();
|
||||
return new HashSet<string>(
|
||||
collection.Families
|
||||
.Select(item => NormalizeFontName(item.Name))
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static Dictionary<string, HashSet<string>> BuildDocumentFontAliasMap(WordprocessingDocument document)
|
||||
{
|
||||
var result = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
var fontTable = document.MainDocumentPart == null || document.MainDocumentPart.FontTablePart == null
|
||||
? null
|
||||
: document.MainDocumentPart.FontTablePart.Fonts;
|
||||
if (fontTable == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var font in fontTable.Elements<W.Font>())
|
||||
{
|
||||
var primary = NormalizeFontName(font.Name == null ? null : font.Name.Value);
|
||||
var alternate = NormalizeFontName(font.AltName == null || font.AltName.Val == null ? null : font.AltName.Val.Value);
|
||||
AddAliasPair(result, primary, alternate);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IEnumerable<OpenXmlPartRootElement> EnumerateRelevantRoots(WordprocessingDocument document)
|
||||
{
|
||||
var main = document.MainDocumentPart;
|
||||
if (main == null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (main.Document != null)
|
||||
{
|
||||
yield return main.Document;
|
||||
}
|
||||
|
||||
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 && IsRelevantPart(part))
|
||||
{
|
||||
yield return part.RootElement;
|
||||
}
|
||||
|
||||
queue.Enqueue(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsRelevantPart(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 Dictionary<string, StyleFontSet> BuildStyleIndex(WordprocessingDocument document)
|
||||
{
|
||||
var result = new Dictionary<string, StyleFontSet>(StringComparer.OrdinalIgnoreCase);
|
||||
var styles = document.MainDocumentPart == null || document.MainDocumentPart.StyleDefinitionsPart == null
|
||||
? null
|
||||
: document.MainDocumentPart.StyleDefinitionsPart.Styles;
|
||||
|
||||
if (styles == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var style in styles.Elements<W.Style>())
|
||||
{
|
||||
var styleId = style.StyleId == null ? null : style.StyleId.Value;
|
||||
if (string.IsNullOrWhiteSpace(styleId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result[styleId] = new StyleFontSet
|
||||
{
|
||||
StyleId = styleId,
|
||||
BasedOnStyleId = style.BasedOn == null ? null : style.BasedOn.Val == null ? null : style.BasedOn.Val.Value,
|
||||
Fonts = ExtractWordFontSet(style.StyleRunProperties == null ? null : style.StyleRunProperties.RunFonts, null),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static FontReferenceSet ReadDocumentDefaultFonts(WordprocessingDocument document, IDictionary<string, string> themeFonts)
|
||||
{
|
||||
var styles = document.MainDocumentPart == null || document.MainDocumentPart.StyleDefinitionsPart == null
|
||||
? null
|
||||
: document.MainDocumentPart.StyleDefinitionsPart.Styles;
|
||||
var docDefaults = styles == null ? null : styles.DocDefaults;
|
||||
var runDefaults = docDefaults == null ? null : docDefaults.RunPropertiesDefault;
|
||||
var baseStyle = runDefaults == null ? null : runDefaults.RunPropertiesBaseStyle;
|
||||
return ExtractWordFontSet(baseStyle == null ? null : baseStyle.RunFonts, themeFonts);
|
||||
}
|
||||
|
||||
private static IDictionary<string, string> BuildThemeFontMap(WordprocessingDocument document)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var themePart = document.MainDocumentPart == null ? null : document.MainDocumentPart.ThemePart;
|
||||
var theme = themePart == null ? null : themePart.Theme;
|
||||
if (theme == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var majorFont = theme.Descendants().FirstOrDefault(item => string.Equals(item.LocalName, "majorFont", StringComparison.OrdinalIgnoreCase));
|
||||
var minorFont = theme.Descendants().FirstOrDefault(item => string.Equals(item.LocalName, "minorFont", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
AddThemeSlot(result, "majorAscii", majorFont, "latin");
|
||||
AddThemeSlot(result, "majorHAnsi", majorFont, "latin");
|
||||
AddThemeSlot(result, "majorEastAsia", majorFont, "ea");
|
||||
AddThemeSlot(result, "majorBidi", majorFont, "cs");
|
||||
AddThemeSlot(result, "minorAscii", minorFont, "latin");
|
||||
AddThemeSlot(result, "minorHAnsi", minorFont, "latin");
|
||||
AddThemeSlot(result, "minorEastAsia", minorFont, "ea");
|
||||
AddThemeSlot(result, "minorBidi", minorFont, "cs");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AddThemeSlot(IDictionary<string, string> themeFonts, string token, OpenXmlElement schemeRoot, string localName)
|
||||
{
|
||||
if (schemeRoot == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var element = schemeRoot.Elements().FirstOrDefault(item => string.Equals(item.LocalName, localName, StringComparison.OrdinalIgnoreCase));
|
||||
if (element == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var typeface = GetAttributeValue(element, "typeface");
|
||||
var normalized = NormalizeFontName(typeface);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
themeFonts[token] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectWordRunFonts(
|
||||
OpenXmlElement root,
|
||||
IDictionary<string, StyleFontSet> styleIndex,
|
||||
FontReferenceSet defaultFonts,
|
||||
IDictionary<string, string> themeFonts,
|
||||
ISet<string> usedFonts)
|
||||
{
|
||||
foreach (var run in root.Descendants<W.Run>())
|
||||
{
|
||||
if (!ContainsVisibleText(run))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var effectiveFonts = ResolveEffectiveWordFonts(run, styleIndex, defaultFonts, themeFonts);
|
||||
foreach (var font in effectiveFonts.GetDefinedFonts())
|
||||
{
|
||||
usedFonts.Add(font);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectDrawingFonts(OpenXmlElement root, ISet<string> usedFonts)
|
||||
{
|
||||
foreach (var element in root.Descendants())
|
||||
{
|
||||
if (!(string.Equals(element.LocalName, "latin", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(element.LocalName, "ea", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(element.LocalName, "cs", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(element.LocalName, "font", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var typeface = NormalizeFontName(GetAttributeValue(element, "typeface"));
|
||||
if (!string.IsNullOrWhiteSpace(typeface))
|
||||
{
|
||||
usedFonts.Add(typeface);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ContainsVisibleText(W.Run run)
|
||||
{
|
||||
if (run == null || string.IsNullOrWhiteSpace(run.InnerText))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (run.Ancestors<W.DeletedRun>().Any())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return run.RunProperties == null || run.RunProperties.Vanish == null;
|
||||
}
|
||||
|
||||
private static FontReferenceSet ResolveEffectiveWordFonts(
|
||||
W.Run run,
|
||||
IDictionary<string, StyleFontSet> styleIndex,
|
||||
FontReferenceSet defaultFonts,
|
||||
IDictionary<string, string> themeFonts)
|
||||
{
|
||||
var effectiveFonts = new FontReferenceSet();
|
||||
effectiveFonts.ApplyOverride(defaultFonts);
|
||||
|
||||
var paragraph = run.Ancestors<W.Paragraph>().FirstOrDefault();
|
||||
if (paragraph != null && paragraph.ParagraphProperties != null)
|
||||
{
|
||||
var paragraphStyleId = paragraph.ParagraphProperties.ParagraphStyleId == null
|
||||
? null
|
||||
: paragraph.ParagraphProperties.ParagraphStyleId.Val == null
|
||||
? null
|
||||
: paragraph.ParagraphProperties.ParagraphStyleId.Val.Value;
|
||||
effectiveFonts.ApplyOverride(ResolveStyleFonts(paragraphStyleId, styleIndex));
|
||||
}
|
||||
|
||||
var table = run.Ancestors<W.Table>().FirstOrDefault();
|
||||
if (table != null && table.TableProperties != null && table.TableProperties.TableStyle != null)
|
||||
{
|
||||
var tableStyleId = table.TableProperties.TableStyle.Val == null ? null : table.TableProperties.TableStyle.Val.Value;
|
||||
effectiveFonts.ApplyOverride(ResolveStyleFonts(tableStyleId, styleIndex));
|
||||
}
|
||||
|
||||
if (run.RunProperties != null && run.RunProperties.RunStyle != null)
|
||||
{
|
||||
var runStyleId = run.RunProperties.RunStyle.Val == null ? null : run.RunProperties.RunStyle.Val.Value;
|
||||
effectiveFonts.ApplyOverride(ResolveStyleFonts(runStyleId, styleIndex));
|
||||
}
|
||||
|
||||
effectiveFonts.ApplyOverride(ExtractWordFontSet(run.RunProperties == null ? null : run.RunProperties.RunFonts, themeFonts));
|
||||
return effectiveFonts;
|
||||
}
|
||||
|
||||
private static FontReferenceSet ResolveStyleFonts(string styleId, IDictionary<string, StyleFontSet> styleIndex)
|
||||
{
|
||||
var result = new FontReferenceSet();
|
||||
if (string.IsNullOrWhiteSpace(styleId) || styleIndex == null || !styleIndex.TryGetValue(styleId, out var style))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var resolved = ResolveStyleFonts(styleId, styleIndex, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
||||
result.ApplyOverride(resolved);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static FontReferenceSet ResolveStyleFonts(
|
||||
string styleId,
|
||||
IDictionary<string, StyleFontSet> styleIndex,
|
||||
ISet<string> visited)
|
||||
{
|
||||
var result = new FontReferenceSet();
|
||||
if (string.IsNullOrWhiteSpace(styleId)
|
||||
|| styleIndex == null
|
||||
|| !styleIndex.TryGetValue(styleId, out var style)
|
||||
|| !visited.Add(styleId))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result.ApplyOverride(ResolveStyleFonts(style.BasedOnStyleId, styleIndex, visited));
|
||||
result.ApplyOverride(style.Fonts);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static FontReferenceSet ExtractWordFontSet(W.RunFonts runFonts, IDictionary<string, string> themeFonts)
|
||||
{
|
||||
var result = new FontReferenceSet();
|
||||
if (runFonts == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Ascii = ResolveFontName(GetAttributeValue(runFonts, "ascii"), GetAttributeValue(runFonts, "asciiTheme"), themeFonts);
|
||||
result.HighAnsi = ResolveFontName(GetAttributeValue(runFonts, "hAnsi"), GetAttributeValue(runFonts, "hAnsiTheme"), themeFonts);
|
||||
result.EastAsia = ResolveFontName(GetAttributeValue(runFonts, "eastAsia"), GetAttributeValue(runFonts, "eastAsiaTheme"), themeFonts);
|
||||
result.ComplexScript = ResolveFontName(GetAttributeValue(runFonts, "cs"), GetAttributeValue(runFonts, "cstheme"), themeFonts);
|
||||
if (string.IsNullOrWhiteSpace(result.ComplexScript))
|
||||
{
|
||||
result.ComplexScript = ResolveFontName(null, GetAttributeValue(runFonts, "csTheme"), themeFonts);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ResolveFontName(string directValue, string themeToken, IDictionary<string, string> themeFonts)
|
||||
{
|
||||
var normalizedDirect = NormalizeFontName(directValue);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedDirect))
|
||||
{
|
||||
return normalizedDirect;
|
||||
}
|
||||
|
||||
var normalizedToken = NormalizeFontName(themeToken);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedToken)
|
||||
&& themeFonts != null
|
||||
&& themeFonts.TryGetValue(normalizedToken, out var themeFont))
|
||||
{
|
||||
return NormalizeFontName(themeFont);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeFontName(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = value.Trim().Trim('"', '\'');
|
||||
if (normalized.StartsWith("@", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized.Substring(1);
|
||||
}
|
||||
|
||||
return normalized.Length == 0 ? null : normalized;
|
||||
}
|
||||
|
||||
private static string GetAttributeValue(OpenXmlElement element, string localName)
|
||||
{
|
||||
if (element == null || string.IsNullOrWhiteSpace(localName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var attribute in element.GetAttributes())
|
||||
{
|
||||
if (string.Equals(attribute.LocalName, localName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return attribute.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool IsFontAvailable(string fontName, IDictionary<string, HashSet<string>> documentFontAliases)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fontName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var installedFonts = _installedFonts.Value;
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var queue = new Queue<string>();
|
||||
queue.Enqueue(fontName);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = NormalizeFontName(queue.Dequeue());
|
||||
if (string.IsNullOrWhiteSpace(current) || !visited.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (installedFonts.Contains(current))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
EnqueueAliases(current, documentFontAliases, queue);
|
||||
EnqueueAliases(current, KnownFontAliases.Value, queue);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Dictionary<string, HashSet<string>> BuildKnownFontAliases()
|
||||
{
|
||||
var result = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var group in KnownFontAliasGroups)
|
||||
{
|
||||
if (group == null || group.Length < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var index = 0; index < group.Length; index++)
|
||||
{
|
||||
var current = NormalizeFontName(group[index]);
|
||||
if (string.IsNullOrWhiteSpace(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var inner = 0; inner < group.Length; inner++)
|
||||
{
|
||||
if (inner == index)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddAliasPair(result, current, group[inner]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void EnqueueAliases(string fontName, IDictionary<string, HashSet<string>> aliases, Queue<string> queue)
|
||||
{
|
||||
if (queue == null || aliases == null || string.IsNullOrWhiteSpace(fontName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!aliases.TryGetValue(fontName, out var values) || values == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
var normalized = NormalizeFontName(value);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
queue.Enqueue(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddAliasPair(IDictionary<string, HashSet<string>> aliases, string left, string right)
|
||||
{
|
||||
var normalizedLeft = NormalizeFontName(left);
|
||||
var normalizedRight = NormalizeFontName(right);
|
||||
if (string.IsNullOrWhiteSpace(normalizedLeft)
|
||||
|| string.IsNullOrWhiteSpace(normalizedRight)
|
||||
|| string.Equals(normalizedLeft, normalizedRight, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddAliasValue(aliases, normalizedLeft, normalizedRight);
|
||||
AddAliasValue(aliases, normalizedRight, normalizedLeft);
|
||||
}
|
||||
|
||||
private static void AddAliasValue(IDictionary<string, HashSet<string>> aliases, string key, string value)
|
||||
{
|
||||
if (!aliases.TryGetValue(key, out var values))
|
||||
{
|
||||
values = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
aliases[key] = values;
|
||||
}
|
||||
|
||||
values.Add(value);
|
||||
}
|
||||
|
||||
private sealed class StyleFontSet
|
||||
{
|
||||
public string StyleId { get; set; }
|
||||
|
||||
public string BasedOnStyleId { get; set; }
|
||||
|
||||
public FontReferenceSet Fonts { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FontValidationResult
|
||||
{
|
||||
public FontValidationResult(IList<string> usedFonts, IList<string> missingFonts)
|
||||
{
|
||||
UsedFonts = usedFonts ?? Array.Empty<string>();
|
||||
MissingFonts = missingFonts ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
public IList<string> UsedFonts { get; private set; }
|
||||
|
||||
public IList<string> MissingFonts { get; private set; }
|
||||
}
|
||||
|
||||
internal sealed class FontReferenceSet
|
||||
{
|
||||
public string Ascii { get; set; }
|
||||
|
||||
public string HighAnsi { get; set; }
|
||||
|
||||
public string EastAsia { get; set; }
|
||||
|
||||
public string ComplexScript { get; set; }
|
||||
|
||||
public IEnumerable<string> GetDefinedFonts()
|
||||
{
|
||||
return new[] { Ascii, HighAnsi, EastAsia, ComplexScript }
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public void ApplyOverride(FontReferenceSet other)
|
||||
{
|
||||
if (other == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(other.Ascii))
|
||||
{
|
||||
Ascii = other.Ascii;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(other.HighAnsi))
|
||||
{
|
||||
HighAnsi = other.HighAnsi;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(other.EastAsia))
|
||||
{
|
||||
EastAsia = other.EastAsia;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(other.ComplexScript))
|
||||
{
|
||||
ComplexScript = other.ComplexScript;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
DCIT.Core/Services/IDocConversionService.cs
Normal file
7
DCIT.Core/Services/IDocConversionService.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public interface IDocConversionService
|
||||
{
|
||||
void Convert(string sourcePath, string outputPath);
|
||||
}
|
||||
}
|
||||
7
DCIT.Core/Services/IDocumentFieldUpdateService.cs
Normal file
7
DCIT.Core/Services/IDocumentFieldUpdateService.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public interface IDocumentFieldUpdateService
|
||||
{
|
||||
void UpdateDynamicContent(string documentPath);
|
||||
}
|
||||
}
|
||||
13
DCIT.Core/Services/IDocumentProcessingService.cs
Normal file
13
DCIT.Core/Services/IDocumentProcessingService.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using DCIT.Core.Models;
|
||||
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public interface IDocumentProcessingService
|
||||
{
|
||||
FileProcessResult Replace(ReplaceFileRequest request, IProgress<FileProcessProgress> progress, CancellationToken cancellationToken);
|
||||
|
||||
FileProcessResult Restore(RestoreFileRequest request, IProgress<FileProcessProgress> progress, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
9
DCIT.Core/Services/IFontValidationService.cs
Normal file
9
DCIT.Core/Services/IFontValidationService.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public interface IFontValidationService
|
||||
{
|
||||
FontValidationResult Validate(string docxPath);
|
||||
|
||||
FontValidationResult EnsureFontsAvailable(string docxPath);
|
||||
}
|
||||
}
|
||||
110
DCIT.Core/Services/LogService.cs
Normal file
110
DCIT.Core/Services/LogService.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using DCIT.Core.Models;
|
||||
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public class LogService
|
||||
{
|
||||
private readonly object _syncRoot = new object();
|
||||
private readonly string _logFilePath;
|
||||
private bool _runtimeFailureActive;
|
||||
|
||||
public LogService(string startupDirectory)
|
||||
{
|
||||
_logFilePath = Path.Combine(startupDirectory, string.Format("session-{0:yyyyMMdd}.log", DateTime.Today));
|
||||
EnsureWritable();
|
||||
}
|
||||
|
||||
public event EventHandler<LogEntry> EntryWritten;
|
||||
|
||||
public event EventHandler<LogWriteFailureEventArgs> WriteFailed;
|
||||
|
||||
public string LogFilePath
|
||||
{
|
||||
get { return _logFilePath; }
|
||||
}
|
||||
|
||||
public bool HasRuntimeFailure { get; private set; }
|
||||
|
||||
public Exception LastRuntimeFailure { get; private set; }
|
||||
|
||||
public void Info(string eventCode, string message, string detail = null)
|
||||
{
|
||||
Write(LogLevel.Info, eventCode, message, detail);
|
||||
}
|
||||
|
||||
public void Warning(string eventCode, string message, string detail = null)
|
||||
{
|
||||
Write(LogLevel.Warning, eventCode, message, detail);
|
||||
}
|
||||
|
||||
public void Error(string eventCode, string message, string detail = null)
|
||||
{
|
||||
Write(LogLevel.Error, eventCode, message, detail);
|
||||
}
|
||||
|
||||
public void Write(LogLevel level, string eventCode, string message, string detail = null)
|
||||
{
|
||||
var entry = new LogEntry
|
||||
{
|
||||
Level = level,
|
||||
EventCode = eventCode,
|
||||
Message = message,
|
||||
Detail = detail,
|
||||
};
|
||||
|
||||
var line = string.Format(
|
||||
"{0:yyyy-MM-dd HH:mm:ss.fff}\t{1}\t{2}\t{3}",
|
||||
entry.Timestamp,
|
||||
level.ToString().ToUpperInvariant(),
|
||||
entry.EventCode ?? string.Empty,
|
||||
entry.Message ?? string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.Detail))
|
||||
{
|
||||
line += "\t" + entry.Detail.Replace("\r", " ").Replace("\n", " ");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
File.AppendAllText(_logFilePath, line + Environment.NewLine, new UTF8Encoding(false));
|
||||
_runtimeFailureActive = false;
|
||||
HasRuntimeFailure = false;
|
||||
LastRuntimeFailure = null;
|
||||
}
|
||||
|
||||
EntryWritten?.Invoke(this, entry);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var shouldRaiseEvent = false;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
HasRuntimeFailure = true;
|
||||
LastRuntimeFailure = ex;
|
||||
if (!_runtimeFailureActive)
|
||||
{
|
||||
_runtimeFailureActive = true;
|
||||
shouldRaiseEvent = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRaiseEvent)
|
||||
{
|
||||
WriteFailed?.Invoke(this, new LogWriteFailureEventArgs(entry, _logFilePath, ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureWritable()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_logFilePath);
|
||||
Directory.CreateDirectory(directory);
|
||||
File.AppendAllText(_logFilePath, string.Empty, new UTF8Encoding(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
518
DCIT.Core/Services/OfficeDocConversionService.cs
Normal file
518
DCIT.Core/Services/OfficeDocConversionService.cs
Normal file
@@ -0,0 +1,518 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public class OfficeDocConversionService : IDocConversionService, IDocumentFieldUpdateService
|
||||
{
|
||||
private const int WdFormatDocument97 = 0;
|
||||
private const int WdFormatDocumentDefault = 16;
|
||||
private static readonly string[] WpsProgIds =
|
||||
{
|
||||
"KWPS.Application",
|
||||
"kwps.Application",
|
||||
"WPS.Application",
|
||||
"wps.Application",
|
||||
};
|
||||
|
||||
private readonly string _applicationBaseDirectory;
|
||||
|
||||
public OfficeDocConversionService(string applicationBaseDirectory = null)
|
||||
{
|
||||
_applicationBaseDirectory = string.IsNullOrWhiteSpace(applicationBaseDirectory)
|
||||
? AppContext.BaseDirectory
|
||||
: applicationBaseDirectory;
|
||||
}
|
||||
|
||||
public void Convert(string sourcePath, string outputPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourcePath))
|
||||
{
|
||||
throw new ArgumentNullException("sourcePath");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
throw new ArgumentNullException("outputPath");
|
||||
}
|
||||
|
||||
var sourceExtension = NormalizeExtension(sourcePath);
|
||||
var outputExtension = NormalizeExtension(outputPath);
|
||||
if (!IsSupportedConversion(sourceExtension, outputExtension))
|
||||
{
|
||||
throw new NotSupportedException("仅支持 .doc 与 .docx 之间互相转换。");
|
||||
}
|
||||
|
||||
var outputDirectory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
}
|
||||
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
File.Delete(outputPath);
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
foreach (var progId in GetOfficeAutomationProgIds())
|
||||
{
|
||||
try
|
||||
{
|
||||
ConvertWithOfficeAutomationBackend(progId, sourcePath, outputPath, outputExtension);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(progId + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
var docToPath = ResolveDocToPath();
|
||||
if (!string.IsNullOrWhiteSpace(docToPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
ConvertWithDocToBackend(docToPath, sourcePath, outputPath, outputExtension);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add("DocTo: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"未找到可用的 .doc 转换链路。请确认已安装可自动化的 Microsoft Word 或 WPS;如自动化不可用,可提供 docto.exe 作为兜底转换工具。"
|
||||
+ Environment.NewLine
|
||||
+ string.Join(Environment.NewLine, errors.Where(item => !string.IsNullOrWhiteSpace(item))));
|
||||
}
|
||||
|
||||
public void UpdateDynamicContent(string documentPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(documentPath))
|
||||
{
|
||||
throw new ArgumentNullException("documentPath");
|
||||
}
|
||||
|
||||
if (!File.Exists(documentPath))
|
||||
{
|
||||
throw new FileNotFoundException("要更新动态内容的文档不存在。", documentPath);
|
||||
}
|
||||
|
||||
var extension = NormalizeExtension(documentPath);
|
||||
if (!string.Equals(extension, ".docx", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(extension, ".doc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new NotSupportedException("动态内容更新仅支持 .doc 或 .docx 文档。");
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
foreach (var progId in GetOfficeAutomationProgIds())
|
||||
{
|
||||
try
|
||||
{
|
||||
UpdateDynamicContentWithOfficeAutomationBackend(progId, documentPath);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(progId + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"未找到可用的目录/域刷新链路。请确保已安装可自动化的 Microsoft Word 或 WPS。"
|
||||
+ Environment.NewLine
|
||||
+ string.Join(Environment.NewLine, errors.Where(item => !string.IsNullOrWhiteSpace(item))));
|
||||
}
|
||||
|
||||
protected virtual IEnumerable<string> GetOfficeAutomationProgIds()
|
||||
{
|
||||
yield return "Word.Application";
|
||||
foreach (var progId in WpsProgIds)
|
||||
{
|
||||
yield return progId;
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual string ResolveDocToPath()
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
var envPath = Environment.GetEnvironmentVariable("DCIT_DOCTO_PATH");
|
||||
if (!string.IsNullOrWhiteSpace(envPath))
|
||||
{
|
||||
candidates.Add(envPath);
|
||||
}
|
||||
|
||||
candidates.Add(Path.Combine(_applicationBaseDirectory, "docto.exe"));
|
||||
candidates.Add(Path.Combine(_applicationBaseDirectory, "tools", "DocTo", "docto.exe"));
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(candidate) && File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
foreach (var segment in pathEnv.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidate = Path.Combine(segment.Trim(), "docto.exe");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected virtual void ConvertWithDocToBackend(string docToPath, string sourcePath, string outputPath, string outputExtension)
|
||||
{
|
||||
var format = string.Equals(outputExtension, ".docx", StringComparison.OrdinalIgnoreCase)
|
||||
? "wdFormatDocumentDefault"
|
||||
: "wdFormatDocument97";
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = docToPath,
|
||||
Arguments = string.Format("-F \"{0}\" -O \"{1}\" -T {2}", sourcePath, outputPath, format),
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(docToPath) ?? _applicationBaseDirectory,
|
||||
};
|
||||
|
||||
using (var process = Process.Start(startInfo))
|
||||
{
|
||||
if (process == null)
|
||||
{
|
||||
throw new InvalidOperationException("无法启动 docto.exe。");
|
||||
}
|
||||
|
||||
var standardOutput = process.StandardOutput.ReadToEnd();
|
||||
var standardError = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit(120000);
|
||||
|
||||
if (process.ExitCode != 0 || !File.Exists(outputPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"docto.exe 转换失败。"
|
||||
+ Environment.NewLine
|
||||
+ (string.IsNullOrWhiteSpace(standardOutput) ? string.Empty : standardOutput + Environment.NewLine)
|
||||
+ standardError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual void ConvertWithOfficeAutomationBackend(string progId, string sourcePath, string outputPath, string outputExtension)
|
||||
{
|
||||
ConvertWithWordAutomation(progId, sourcePath, outputPath, outputExtension);
|
||||
}
|
||||
|
||||
protected virtual void UpdateDynamicContentWithOfficeAutomationBackend(string progId, string documentPath)
|
||||
{
|
||||
UpdateDynamicContentWithWordAutomation(progId, documentPath);
|
||||
}
|
||||
|
||||
private static bool IsSupportedConversion(string sourceExtension, string outputExtension)
|
||||
{
|
||||
if (string.Equals(sourceExtension, outputExtension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return (string.Equals(sourceExtension, ".doc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(sourceExtension, ".docx", StringComparison.OrdinalIgnoreCase))
|
||||
&& (string.Equals(outputExtension, ".doc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(outputExtension, ".docx", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string NormalizeExtension(string path)
|
||||
{
|
||||
return Path.GetExtension(path) ?? string.Empty;
|
||||
}
|
||||
|
||||
private static void ConvertWithWordAutomation(string progId, string sourcePath, string outputPath, string outputExtension)
|
||||
{
|
||||
var appType = Type.GetTypeFromProgID(progId, false);
|
||||
if (appType == null)
|
||||
{
|
||||
throw new InvalidOperationException("未注册 COM ProgID: " + progId);
|
||||
}
|
||||
|
||||
object application = null;
|
||||
object documents = null;
|
||||
object document = null;
|
||||
|
||||
try
|
||||
{
|
||||
application = Activator.CreateInstance(appType);
|
||||
if (application == null)
|
||||
{
|
||||
throw new InvalidOperationException("无法创建 COM 实例: " + progId);
|
||||
}
|
||||
|
||||
TrySetProperty(application, "Visible", false);
|
||||
TrySetProperty(application, "DisplayAlerts", 0);
|
||||
documents = GetProperty(application, "Documents");
|
||||
if (documents == null)
|
||||
{
|
||||
throw new InvalidOperationException("无法获取 Documents 集合。");
|
||||
}
|
||||
|
||||
document = InvokeMethod(documents, "Open", sourcePath, false, true);
|
||||
if (document == null)
|
||||
{
|
||||
throw new InvalidOperationException("无法打开文档。");
|
||||
}
|
||||
|
||||
var fileFormat = string.Equals(outputExtension, ".docx", StringComparison.OrdinalIgnoreCase)
|
||||
? WdFormatDocumentDefault
|
||||
: WdFormatDocument97;
|
||||
|
||||
try
|
||||
{
|
||||
InvokeMethod(document, "SaveAs2", outputPath, fileFormat);
|
||||
}
|
||||
catch (MissingMethodException)
|
||||
{
|
||||
InvokeMethod(document, "SaveAs", outputPath, fileFormat);
|
||||
}
|
||||
|
||||
if (!File.Exists(outputPath))
|
||||
{
|
||||
throw new InvalidOperationException("Office 自动化未生成目标文件。");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SafeCloseDocument(document);
|
||||
SafeQuitApplication(application);
|
||||
ReleaseComObject(document);
|
||||
ReleaseComObject(documents);
|
||||
ReleaseComObject(application);
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateDynamicContentWithWordAutomation(string progId, string documentPath)
|
||||
{
|
||||
var appType = Type.GetTypeFromProgID(progId, false);
|
||||
if (appType == null)
|
||||
{
|
||||
throw new InvalidOperationException("未注册 COM ProgID: " + progId);
|
||||
}
|
||||
|
||||
object application = null;
|
||||
object documents = null;
|
||||
object document = null;
|
||||
|
||||
try
|
||||
{
|
||||
application = Activator.CreateInstance(appType);
|
||||
if (application == null)
|
||||
{
|
||||
throw new InvalidOperationException("无法创建 COM 实例: " + progId);
|
||||
}
|
||||
|
||||
TrySetProperty(application, "Visible", false);
|
||||
TrySetProperty(application, "DisplayAlerts", 0);
|
||||
documents = GetProperty(application, "Documents");
|
||||
if (documents == null)
|
||||
{
|
||||
throw new InvalidOperationException("无法获取 Documents 集合。");
|
||||
}
|
||||
|
||||
document = InvokeMethod(documents, "Open", documentPath, false, false);
|
||||
if (document == null)
|
||||
{
|
||||
throw new InvalidOperationException("无法打开文档。");
|
||||
}
|
||||
|
||||
TryInvokeMethod(document, "Activate");
|
||||
TryInvokeMethod(document, "Repaginate");
|
||||
TryUpdateCollectionItems(document, "TablesOfContents", "Update", "UpdatePageNumbers");
|
||||
TryUpdateCollectionItems(document, "TablesOfFigures", "Update");
|
||||
TryUpdateCollectionItems(document, "TablesOfAuthorities", "Update");
|
||||
TryUpdateCollection(document, "Fields", "Update");
|
||||
TryUpdateCollection(document, "Indexes", "Update");
|
||||
TryInvokeMethod(document, "Repaginate");
|
||||
InvokeMethod(document, "Save");
|
||||
}
|
||||
finally
|
||||
{
|
||||
SafeCloseDocument(document);
|
||||
SafeQuitApplication(application);
|
||||
ReleaseComObject(document);
|
||||
ReleaseComObject(documents);
|
||||
ReleaseComObject(application);
|
||||
}
|
||||
}
|
||||
|
||||
private static object GetProperty(object instance, string propertyName)
|
||||
{
|
||||
return instance.GetType().InvokeMember(
|
||||
propertyName,
|
||||
System.Reflection.BindingFlags.GetProperty,
|
||||
null,
|
||||
instance,
|
||||
null);
|
||||
}
|
||||
|
||||
private static void TrySetProperty(object instance, string propertyName, object value)
|
||||
{
|
||||
try
|
||||
{
|
||||
instance.GetType().InvokeMember(
|
||||
propertyName,
|
||||
System.Reflection.BindingFlags.SetProperty,
|
||||
null,
|
||||
instance,
|
||||
new[] { value });
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static object InvokeMethod(object instance, string methodName, params object[] arguments)
|
||||
{
|
||||
return instance.GetType().InvokeMember(
|
||||
methodName,
|
||||
System.Reflection.BindingFlags.InvokeMethod,
|
||||
null,
|
||||
instance,
|
||||
arguments);
|
||||
}
|
||||
|
||||
private static bool TryInvokeMethod(object instance, string methodName, params object[] arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
InvokeMethod(instance, methodName, arguments);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryUpdateCollection(object instance, string propertyName, string updateMethodName)
|
||||
{
|
||||
object collection = null;
|
||||
try
|
||||
{
|
||||
collection = GetProperty(instance, propertyName);
|
||||
if (collection == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryInvokeMethod(collection, updateMethodName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseComObject(collection);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryUpdateCollectionItems(object instance, string propertyName, params string[] methodNames)
|
||||
{
|
||||
object collection = null;
|
||||
try
|
||||
{
|
||||
collection = GetProperty(instance, propertyName);
|
||||
if (collection == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var countObject = GetProperty(collection, "Count");
|
||||
var count = System.Convert.ToInt32(countObject);
|
||||
for (var index = 1; index <= count; index++)
|
||||
{
|
||||
object item = null;
|
||||
try
|
||||
{
|
||||
item = InvokeMethod(collection, "Item", index);
|
||||
foreach (var methodName in methodNames.Where(name => !string.IsNullOrWhiteSpace(name)))
|
||||
{
|
||||
TryInvokeMethod(item, methodName);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseComObject(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseComObject(collection);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SafeCloseDocument(object document)
|
||||
{
|
||||
if (document == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
InvokeMethod(document, "Close", false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static void SafeQuitApplication(object application)
|
||||
{
|
||||
if (application == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
InvokeMethod(application, "Quit", false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReleaseComObject(object instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (instance != null && Marshal.IsComObject(instance))
|
||||
{
|
||||
Marshal.FinalReleaseComObject(instance);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
376
DCIT.Core/Services/RuleService.cs
Normal file
376
DCIT.Core/Services/RuleService.cs
Normal file
@@ -0,0 +1,376 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using DCIT.Core.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public class RuleService
|
||||
{
|
||||
private static readonly TimeSpan RegexValidationTimeout = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
public RuleFile Load(string ruleFilePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ruleFilePath) || !System.IO.File.Exists(ruleFilePath))
|
||||
{
|
||||
return new RuleFile();
|
||||
}
|
||||
|
||||
var content = System.IO.File.ReadAllText(ruleFilePath, Encoding.UTF8);
|
||||
return JsonConvert.DeserializeObject<RuleFile>(content) ?? new RuleFile();
|
||||
}
|
||||
|
||||
public void Save(string ruleFilePath, RuleFile ruleFile)
|
||||
{
|
||||
var report = Validate(ruleFile);
|
||||
if (report.HasErrors)
|
||||
{
|
||||
var detail = string.Join(Environment.NewLine, report.Issues.Select(FormatIssue));
|
||||
throw new InvalidOperationException(detail);
|
||||
}
|
||||
|
||||
var content = JsonConvert.SerializeObject(ruleFile, Formatting.Indented);
|
||||
System.IO.File.WriteAllText(ruleFilePath, content, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
public RuleValidationReport Validate(RuleFile ruleFile)
|
||||
{
|
||||
ruleFile = ruleFile ?? new RuleFile();
|
||||
|
||||
var report = new RuleValidationReport();
|
||||
var idSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var validatedRules = new List<ValidatedRuleContext>();
|
||||
|
||||
for (var index = 0; index < ruleFile.Rules.Count; index++)
|
||||
{
|
||||
var rule = ruleFile.Rules[index] ?? new RuleDefinition();
|
||||
if (string.IsNullOrWhiteSpace(rule.Id))
|
||||
{
|
||||
report.Issues.Add(CreateIssue(RuleValidationSeverity.Error, index, rule, "规则 ID 不能为空。"));
|
||||
}
|
||||
else if (!idSet.Add(rule.Id.Trim()))
|
||||
{
|
||||
report.Issues.Add(CreateIssue(RuleValidationSeverity.Error, index, rule, "规则 ID 重复。"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rule.Name))
|
||||
{
|
||||
report.Issues.Add(CreateIssue(RuleValidationSeverity.Warning, index, rule, "规则名称为空。"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rule.SearchText))
|
||||
{
|
||||
report.Issues.Add(CreateIssue(RuleValidationSeverity.Error, index, rule, "原文本或表达式不能为空。"));
|
||||
continue;
|
||||
}
|
||||
|
||||
Regex compiledRegex = null;
|
||||
if (rule.MatchMode == RuleMatchMode.RegularExpression)
|
||||
{
|
||||
try
|
||||
{
|
||||
compiledRegex = new Regex(rule.SearchText, GetRegexOptions(rule), RegexValidationTimeout);
|
||||
ValidateRegexRule(report, index, rule, compiledRegex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
report.Issues.Add(CreateIssue(RuleValidationSeverity.Error, index, rule, "正则表达式非法: " + ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.MatchMode == RuleMatchMode.PlainText
|
||||
&& !string.IsNullOrEmpty(rule.ReplacementText)
|
||||
&& rule.ReplacementText.IndexOf(rule.SearchText, rule.IsCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
report.Issues.Add(CreateIssue(RuleValidationSeverity.Warning, index, rule, "替换文本会再次命中原文本,请确认是否符合预期。"));
|
||||
}
|
||||
|
||||
if (rule.MatchMode == RuleMatchMode.RegularExpression
|
||||
&& compiledRegex != null
|
||||
&& !string.IsNullOrEmpty(rule.ReplacementText)
|
||||
&& IsRegexReplacementSelfMatching(compiledRegex, rule.ReplacementText))
|
||||
{
|
||||
report.Issues.Add(CreateIssue(RuleValidationSeverity.Warning, index, rule, "替换模板会再次命中当前规则,请确认是否符合预期。"));
|
||||
}
|
||||
|
||||
var current = new ValidatedRuleContext(index, rule, compiledRegex);
|
||||
foreach (var previous in validatedRules)
|
||||
{
|
||||
AddCompatibilityIssues(report, previous, current);
|
||||
}
|
||||
|
||||
validatedRules.Add(current);
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
public RuleDefinition CreateAutoExtractRule(ExtractionCandidate candidate, IList<RuleDefinition> existingRules)
|
||||
{
|
||||
var prefix = GetPrefix(candidate.PrimaryCategory);
|
||||
var namePrefix = GetNamePrefix(candidate.PrimaryCategory);
|
||||
var nextIndex = 1;
|
||||
var existingIds = new HashSet<string>(existingRules.Select(rule => rule.Id ?? string.Empty), StringComparer.OrdinalIgnoreCase);
|
||||
var existingNames = new HashSet<string>(existingRules.Select(rule => rule.Name ?? string.Empty), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
string id;
|
||||
do
|
||||
{
|
||||
id = string.Format("{0}{1:0000}", prefix, nextIndex++);
|
||||
}
|
||||
while (existingIds.Contains(id));
|
||||
|
||||
nextIndex = 1;
|
||||
string name;
|
||||
do
|
||||
{
|
||||
name = string.Format("{0}-{1:0000}", namePrefix, nextIndex++);
|
||||
}
|
||||
while (existingNames.Contains(name));
|
||||
|
||||
return new RuleDefinition
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
IsEnabled = true,
|
||||
MatchMode = RuleMatchMode.PlainText,
|
||||
IsCaseSensitive = false,
|
||||
IsWholeWord = false,
|
||||
SearchText = candidate.OriginalText,
|
||||
ReplacementText = string.Empty,
|
||||
Note = string.Format(
|
||||
"自动提取; 类型={0}; 统计={1}; 首次文档={2}; 时间={3:yyyy-MM-dd HH:mm:ss}",
|
||||
candidate.CategoryDisplay,
|
||||
candidate.StatisticDisplay,
|
||||
candidate.FirstDocumentPath,
|
||||
DateTime.Now),
|
||||
};
|
||||
}
|
||||
|
||||
public string FormatIssue(RuleValidationIssue issue)
|
||||
{
|
||||
return string.Format(
|
||||
"[{0}] 规则#{1} ({2}/{3}) {4}",
|
||||
issue.Severity,
|
||||
issue.Index + 1,
|
||||
issue.RuleId ?? "-",
|
||||
issue.RuleName ?? "-",
|
||||
issue.Message);
|
||||
}
|
||||
|
||||
private static RuleValidationIssue CreateIssue(RuleValidationSeverity severity, int index, RuleDefinition rule, string message)
|
||||
{
|
||||
return new RuleValidationIssue
|
||||
{
|
||||
Severity = severity,
|
||||
Index = index,
|
||||
RuleId = rule.Id,
|
||||
RuleName = rule.Name,
|
||||
Message = message,
|
||||
};
|
||||
}
|
||||
|
||||
private static RegexOptions GetRegexOptions(RuleDefinition rule)
|
||||
{
|
||||
var options = RegexOptions.CultureInvariant;
|
||||
if (!rule.IsCaseSensitive)
|
||||
{
|
||||
options |= RegexOptions.IgnoreCase;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private static void ValidateRegexRule(RuleValidationReport report, int index, RuleDefinition rule, Regex regex)
|
||||
{
|
||||
if (MatchesEmptyText(regex))
|
||||
{
|
||||
report.Issues.Add(CreateIssue(RuleValidationSeverity.Error, index, rule, "正则表达式可匹配零长度文本,执行时可能导致死循环。"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
regex.Replace("validation", rule.ReplacementText ?? string.Empty, 1);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
report.Issues.Add(CreateIssue(RuleValidationSeverity.Error, index, rule, "替换模板非法: " + ex.Message));
|
||||
}
|
||||
|
||||
if (HasNestedQuantifierRisk(rule.SearchText))
|
||||
{
|
||||
report.Issues.Add(CreateIssue(RuleValidationSeverity.Warning, index, rule, "正则表达式存在嵌套量词风险,可能导致性能急剧下降。"));
|
||||
}
|
||||
|
||||
if (MayTimeoutDuringValidation(regex))
|
||||
{
|
||||
report.Issues.Add(CreateIssue(RuleValidationSeverity.Warning, index, rule, "正则表达式验证探测时出现超时风险,建议简化表达式。"));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesEmptyText(Regex regex)
|
||||
{
|
||||
var emptyMatch = regex.Match(string.Empty);
|
||||
if (emptyMatch.Success && emptyMatch.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var sampleMatch = regex.Match("A");
|
||||
return sampleMatch.Success && sampleMatch.Length == 0;
|
||||
}
|
||||
|
||||
private static bool IsRegexReplacementSelfMatching(Regex regex, string replacementText)
|
||||
{
|
||||
try
|
||||
{
|
||||
return regex.Match(replacementText ?? string.Empty).Success;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MayTimeoutDuringValidation(Regex regex)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = regex.IsMatch(new string('a', 4096) + "!");
|
||||
return false;
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasNestedQuantifierRisk(string pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Regex.IsMatch(
|
||||
pattern,
|
||||
@"\((?:[^()\\]|\\.)+[+*](?:[^()\\]|\\.)*\)[+*{]",
|
||||
RegexOptions.CultureInvariant);
|
||||
}
|
||||
|
||||
private static void AddCompatibilityIssues(RuleValidationReport report, ValidatedRuleContext previous, ValidatedRuleContext current)
|
||||
{
|
||||
if (previous.Rule.Target != current.Rule.Target)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsEquivalentSearchText(previous.Rule.SearchText, current.Rule.SearchText))
|
||||
{
|
||||
report.Issues.Add(CreateIssue(
|
||||
RuleValidationSeverity.Warning,
|
||||
current.Index,
|
||||
current.Rule,
|
||||
string.Format("当前规则与 #{0} 使用相同原文本或表达式,请确认顺序影响。", previous.Index + 1)));
|
||||
|
||||
if (HaveDifferentMatchingOptions(previous.Rule, current.Rule))
|
||||
{
|
||||
report.Issues.Add(CreateIssue(
|
||||
RuleValidationSeverity.Warning,
|
||||
current.Index,
|
||||
current.Rule,
|
||||
string.Format("当前规则与 #{0} 使用相同原文本但匹配选项或替换文本不同,继续执行结果可能不稳定。", previous.Index + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.Rule.MatchMode == RuleMatchMode.PlainText
|
||||
&& current.Rule.MatchMode == RuleMatchMode.PlainText
|
||||
&& HasPlainTextOverlap(previous.Rule, current.Rule))
|
||||
{
|
||||
report.Issues.Add(CreateIssue(
|
||||
RuleValidationSeverity.Warning,
|
||||
current.Index,
|
||||
current.Rule,
|
||||
string.Format("当前规则与 #{0} 存在重叠命中风险,请确认执行顺序。", previous.Index + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsEquivalentSearchText(string left, string right)
|
||||
{
|
||||
return string.Equals(left ?? string.Empty, right ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool HaveDifferentMatchingOptions(RuleDefinition left, RuleDefinition right)
|
||||
{
|
||||
return left.MatchMode != right.MatchMode
|
||||
|| left.IsCaseSensitive != right.IsCaseSensitive
|
||||
|| left.IsWholeWord != right.IsWholeWord
|
||||
|| !string.Equals(left.ReplacementText ?? string.Empty, right.ReplacementText ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool HasPlainTextOverlap(RuleDefinition left, RuleDefinition right)
|
||||
{
|
||||
if (string.IsNullOrEmpty(left.SearchText) || string.IsNullOrEmpty(right.SearchText))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (left.IsWholeWord && right.IsWholeWord)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var comparison = left.IsCaseSensitive && right.IsCaseSensitive
|
||||
? StringComparison.Ordinal
|
||||
: StringComparison.OrdinalIgnoreCase;
|
||||
|
||||
return left.SearchText.IndexOf(right.SearchText, comparison) >= 0
|
||||
|| right.SearchText.IndexOf(left.SearchText, comparison) >= 0;
|
||||
}
|
||||
|
||||
private static string GetPrefix(AutoExtractCategory category)
|
||||
{
|
||||
switch (category)
|
||||
{
|
||||
case AutoExtractCategory.Keyword:
|
||||
return "AK";
|
||||
case AutoExtractCategory.HighFrequencyWord:
|
||||
return "AW";
|
||||
default:
|
||||
return "AS";
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetNamePrefix(AutoExtractCategory category)
|
||||
{
|
||||
switch (category)
|
||||
{
|
||||
case AutoExtractCategory.Keyword:
|
||||
return "AUTO-KW";
|
||||
case AutoExtractCategory.HighFrequencyWord:
|
||||
return "AUTO-WORD";
|
||||
default:
|
||||
return "AUTO-SENT";
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ValidatedRuleContext
|
||||
{
|
||||
public ValidatedRuleContext(int index, RuleDefinition rule, Regex compiledRegex)
|
||||
{
|
||||
Index = index;
|
||||
Rule = rule;
|
||||
CompiledRegex = compiledRegex;
|
||||
}
|
||||
|
||||
public int Index { get; private set; }
|
||||
|
||||
public RuleDefinition Rule { get; private set; }
|
||||
|
||||
public Regex CompiledRegex { get; private set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
143
DCIT.Core/Services/RuntimeEnvironmentValidationService.cs
Normal file
143
DCIT.Core/Services/RuntimeEnvironmentValidationService.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using DCIT.Core.Models;
|
||||
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public class RuntimeEnvironmentValidationService
|
||||
{
|
||||
private static readonly string[] WpsProgIds =
|
||||
{
|
||||
"KWPS.Application",
|
||||
"kwps.Application",
|
||||
"WPS.Application",
|
||||
"wps.Application",
|
||||
};
|
||||
|
||||
private readonly string _startupDirectory;
|
||||
|
||||
public RuntimeEnvironmentValidationService(string startupDirectory = null)
|
||||
{
|
||||
_startupDirectory = string.IsNullOrWhiteSpace(startupDirectory)
|
||||
? AppContext.BaseDirectory
|
||||
: startupDirectory;
|
||||
}
|
||||
|
||||
public RuntimeEnvironmentValidationResult Validate()
|
||||
{
|
||||
var result = new RuntimeEnvironmentValidationResult
|
||||
{
|
||||
StartupDirectory = _startupDirectory,
|
||||
};
|
||||
|
||||
result.IsStartupDirectoryWritable = CanWriteToDirectory(_startupDirectory);
|
||||
if (!result.IsStartupDirectoryWritable)
|
||||
{
|
||||
result.BlockingIssues.Add("程序启动目录不可写。请将程序部署到当前用户具有写权限的目录后再启动。");
|
||||
}
|
||||
|
||||
result.ResolvedAutomationProgId = ResolveAutomationProgId();
|
||||
if (string.IsNullOrWhiteSpace(result.ResolvedAutomationProgId))
|
||||
{
|
||||
result.BlockingIssues.Add("未检测到可自动化的 Microsoft Word 或 WPS 组件。当前环境无法满足目录/域刷新及 .doc 处理要求。");
|
||||
}
|
||||
|
||||
result.ResolvedDocToPath = ResolveDocToPath();
|
||||
if (string.IsNullOrWhiteSpace(result.ResolvedDocToPath))
|
||||
{
|
||||
result.Warnings.Add("未检测到 docto.exe,.doc 兼容性将仅依赖 Word/WPS 自动化链路。");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected virtual IEnumerable<string> GetOfficeAutomationProgIds()
|
||||
{
|
||||
yield return "Word.Application";
|
||||
foreach (var progId in WpsProgIds)
|
||||
{
|
||||
yield return progId;
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual bool IsProgIdRegistered(string progId)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(progId)
|
||||
&& Type.GetTypeFromProgID(progId, false) != null;
|
||||
}
|
||||
|
||||
protected virtual string ResolveDocToPath()
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
var envPath = Environment.GetEnvironmentVariable("DCIT_DOCTO_PATH");
|
||||
if (!string.IsNullOrWhiteSpace(envPath))
|
||||
{
|
||||
candidates.Add(envPath);
|
||||
}
|
||||
|
||||
candidates.Add(Path.Combine(_startupDirectory, "docto.exe"));
|
||||
candidates.Add(Path.Combine(_startupDirectory, "tools", "DocTo", "docto.exe"));
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(candidate) && File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
foreach (var segment in pathEnv.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidate = Path.Combine(segment.Trim(), "docto.exe");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected virtual bool CanWriteToDirectory(string directoryPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directoryPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directoryPath);
|
||||
var probePath = Path.Combine(directoryPath, ".dcit-writecheck-" + Guid.NewGuid().ToString("N") + ".tmp");
|
||||
File.WriteAllText(probePath, "probe");
|
||||
File.Delete(probePath);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveAutomationProgId()
|
||||
{
|
||||
foreach (var progId in GetOfficeAutomationProgIds())
|
||||
{
|
||||
if (IsProgIdRegistered(progId))
|
||||
{
|
||||
return progId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
183
DCIT.Core/Services/TemporaryFileCleanupService.cs
Normal file
183
DCIT.Core/Services/TemporaryFileCleanupService.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using DCIT.Core.Models;
|
||||
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public sealed class TemporaryFileCleanupService
|
||||
{
|
||||
private static readonly Regex AtomicTempFilePattern = new Regex(@"\.tmp-[0-9a-fA-F]{32}(?=\.)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private readonly string _tempRoot;
|
||||
|
||||
public TemporaryFileCleanupService()
|
||||
: this(Path.Combine(Path.GetTempPath(), "DCIT"))
|
||||
{
|
||||
}
|
||||
|
||||
public TemporaryFileCleanupService(string tempRoot)
|
||||
{
|
||||
_tempRoot = string.IsNullOrWhiteSpace(tempRoot)
|
||||
? Path.Combine(Path.GetTempPath(), "DCIT")
|
||||
: tempRoot;
|
||||
}
|
||||
|
||||
public TemporaryCleanupResult Cleanup(AppConfig config)
|
||||
{
|
||||
var result = new TemporaryCleanupResult();
|
||||
CleanupDedicatedTempDirectory(Path.Combine(_tempRoot, "processing"), result);
|
||||
CleanupDedicatedTempDirectory(Path.Combine(_tempRoot, "extract"), result);
|
||||
|
||||
var configuredOutputDirectories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
AddDirectory(configuredOutputDirectories, config == null || config.ReplacePage == null ? null : config.ReplacePage.OutputDirectory);
|
||||
AddDirectory(configuredOutputDirectories, config == null || config.RestorePage == null ? null : config.RestorePage.RestoreDirectory);
|
||||
|
||||
foreach (var directory in configuredOutputDirectories)
|
||||
{
|
||||
CleanupAtomicTempFiles(directory, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AddDirectory(ISet<string> directories, string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
directories.Add(Path.GetFullPath(path));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanupAtomicTempFiles(string rootDirectory, TemporaryCleanupResult result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IEnumerable<string> files;
|
||||
try
|
||||
{
|
||||
files = Directory.EnumerateFiles(rootDirectory, "*", SearchOption.AllDirectories)
|
||||
.Where(path => IsAtomicTempFile(Path.GetFileName(path)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Failures.Add("无法扫描目录 " + rootDirectory + ": " + ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
TryDeleteFile(file, result);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAtomicTempFile(string fileName)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(fileName) && AtomicTempFilePattern.IsMatch(fileName);
|
||||
}
|
||||
|
||||
private static void CleanupDedicatedTempDirectory(string rootDirectory, TemporaryCleanupResult result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IEnumerable<string> files;
|
||||
try
|
||||
{
|
||||
files = Directory.EnumerateFiles(rootDirectory, "*", SearchOption.AllDirectories).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Failures.Add("无法扫描目录 " + rootDirectory + ": " + ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
TryDeleteFile(file, result);
|
||||
}
|
||||
|
||||
TryDeleteEmptyDirectories(rootDirectory, result);
|
||||
}
|
||||
|
||||
private static void TryDeleteEmptyDirectories(string rootDirectory, TemporaryCleanupResult result)
|
||||
{
|
||||
IEnumerable<string> directories;
|
||||
try
|
||||
{
|
||||
directories = Directory.EnumerateDirectories(rootDirectory, "*", SearchOption.AllDirectories)
|
||||
.OrderByDescending(path => path.Length)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Failures.Add("无法枚举临时目录 " + rootDirectory + ": " + ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.EnumerateFileSystemEntries(directory).Any())
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Failures.Add("无法删除临时目录 " + directory + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!Directory.EnumerateFileSystemEntries(rootDirectory).Any())
|
||||
{
|
||||
Directory.Delete(rootDirectory);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Failures.Add("无法删除临时目录 " + rootDirectory + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string filePath, TemporaryCleanupResult result)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
result.RemovedFiles.Add(filePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Failures.Add("无法删除临时文件 " + filePath + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TemporaryCleanupResult
|
||||
{
|
||||
public IList<string> RemovedFiles { get; } = new List<string>();
|
||||
|
||||
public IList<string> Failures { get; } = new List<string>();
|
||||
}
|
||||
}
|
||||
212
DCIT.Core/Services/WordTextExtractionService.cs
Normal file
212
DCIT.Core/Services/WordTextExtractionService.cs
Normal file
@@ -0,0 +1,212 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using DCIT.Core.Models;
|
||||
using DCIT.Core.Utilities;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using W = DocumentFormat.OpenXml.Wordprocessing;
|
||||
|
||||
namespace DCIT.Core.Services
|
||||
{
|
||||
public class WordTextExtractionService
|
||||
{
|
||||
private readonly IDocConversionService _docConversionService;
|
||||
|
||||
public WordTextExtractionService()
|
||||
: this(new OfficeDocConversionService())
|
||||
{
|
||||
}
|
||||
|
||||
public WordTextExtractionService(IDocConversionService docConversionService)
|
||||
{
|
||||
_docConversionService = docConversionService;
|
||||
}
|
||||
|
||||
public ExtractedDocumentText ExtractVisibleText(string filePath)
|
||||
{
|
||||
var workingPath = filePath;
|
||||
string temporaryDocxPath = null;
|
||||
|
||||
try
|
||||
{
|
||||
var extension = Path.GetExtension(filePath) ?? string.Empty;
|
||||
if (extension.Equals(".doc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_docConversionService == null)
|
||||
{
|
||||
throw new NotSupportedException("当前环境未配置 .doc 转换服务。");
|
||||
}
|
||||
|
||||
temporaryDocxPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"DCIT",
|
||||
"extract",
|
||||
Guid.NewGuid().ToString("N") + ".docx");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(temporaryDocxPath) ?? Path.GetTempPath());
|
||||
_docConversionService.Convert(filePath, temporaryDocxPath);
|
||||
workingPath = temporaryDocxPath;
|
||||
}
|
||||
|
||||
using (var document = WordprocessingDocument.Open(workingPath, false))
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
var rootElements = GetRelevantRootElements(document);
|
||||
foreach (var root in rootElements)
|
||||
{
|
||||
AppendVisibleText(root, builder);
|
||||
if (builder.Length > 0 && builder[builder.Length - 1] != '\n')
|
||||
{
|
||||
builder.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
return new ExtractedDocumentText
|
||||
{
|
||||
FilePath = filePath,
|
||||
VisibleText = builder.ToString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(temporaryDocxPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<OpenXmlPartRootElement> GetRelevantRootElements(WordprocessingDocument document)
|
||||
{
|
||||
var result = new List<KeyValuePair<string, OpenXmlPartRootElement>>();
|
||||
var partQueue = new Queue<OpenXmlPartContainer>();
|
||||
partQueue.Enqueue(document.MainDocumentPart);
|
||||
var visited = new HashSet<OpenXmlPart>();
|
||||
|
||||
while (partQueue.Count > 0)
|
||||
{
|
||||
var container = partQueue.Dequeue();
|
||||
foreach (var pair in container.Parts)
|
||||
{
|
||||
var part = pair.OpenXmlPart;
|
||||
if (part == null || !visited.Add(part))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.RootElement != null && IsRelevantPart(part))
|
||||
{
|
||||
result.Add(new KeyValuePair<string, OpenXmlPartRootElement>(GetStablePartKey(part), part.RootElement));
|
||||
}
|
||||
|
||||
partQueue.Enqueue(part);
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = result
|
||||
.OrderBy(item => item.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(item => item.Value)
|
||||
.ToList();
|
||||
|
||||
if (document.MainDocumentPart != null && document.MainDocumentPart.Document != null)
|
||||
{
|
||||
ordered.Insert(0, document.MainDocumentPart.Document);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static bool IsRelevantPart(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 GetStablePartKey(OpenXmlPart part)
|
||||
{
|
||||
return part == null || part.Uri == null
|
||||
? string.Empty
|
||||
: part.Uri.ToString();
|
||||
}
|
||||
|
||||
private static void AppendVisibleText(OpenXmlElement root, StringBuilder builder)
|
||||
{
|
||||
foreach (var element in root.Descendants())
|
||||
{
|
||||
var leaf = element as OpenXmlLeafTextElement;
|
||||
if (leaf != null)
|
||||
{
|
||||
if (!IsVisibleLeaf(leaf))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = leaf.Text;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(text);
|
||||
builder.Append(' ');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!VmlTextMetadataUtility.IsVmlShapeElement(element))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var binding in VmlTextMetadataUtility.CreateBindings(element))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(binding.Text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(binding.Text);
|
||||
builder.Append(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsVisibleLeaf(OpenXmlLeafTextElement leaf)
|
||||
{
|
||||
if (leaf is W.FieldCode || leaf is W.DeletedText)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (leaf.Ancestors<W.DeletedRun>().Any())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var run = leaf.Ancestors<W.Run>().FirstOrDefault();
|
||||
if (run != null && run.RunProperties != null && run.RunProperties.Vanish != null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user