feat: add learned document template workflow
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocxWorkbench.Core;
|
||||
|
||||
namespace DocxWorkbench.WordInterop;
|
||||
|
||||
public sealed class TemplateDocumentGenerator : ITemplateDocumentGenerator, IDisposable
|
||||
{
|
||||
private readonly ITemplatePackageStore _packages;
|
||||
private readonly IWordDocumentNormalizer _normalizer;
|
||||
private readonly WordDocumentService _word = new();
|
||||
private readonly IDocumentInspector _inspector;
|
||||
public TemplateDocumentGenerator(ITemplatePackageStore packages, IWordDocumentNormalizer normalizer, IDocumentInspector inspector) { _packages = packages; _normalizer = normalizer; _inspector = inspector; }
|
||||
|
||||
public async Task<TemplateGenerationResult> GenerateAsync(TemplateGenerationRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var opened = _packages.Open(request.TemplatePackagePath); DocumentNormalizationResult? normalizedBody = null; var outputPdf = Path.ChangeExtension(request.OutputDocxPath, ".pdf");
|
||||
try
|
||||
{
|
||||
var bodyPath = request.ContentSourcePath;
|
||||
if (Path.GetExtension(bodyPath).Equals(".doc", StringComparison.OrdinalIgnoreCase)) { normalizedBody = await _normalizer.NormalizeAsync(new DocumentNormalizationRequest { SourcePath = bodyPath, WorkingDirectory = Path.Combine(opened.WorkingDirectory, "body") }, progress, cancellationToken); bodyPath = normalizedBody.NormalizedPath; }
|
||||
var workingTemplate = Path.Combine(opened.WorkingDirectory, "working-template.docx"); File.Copy(opened.TemplateDocxPath, workingTemplate, true); ReplaceScalarTokens(workingTemplate, request.ScalarValues); progress?.Report(new WordOperationProgress { Stage = "Generate", Message = "Building document from learned template" });
|
||||
var result = await _word.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = workingTemplate, BodyPath = bodyPath, OutputDocxPath = request.OutputDocxPath }, progress is null ? null : new Progress<string>(x => progress.Report(new WordOperationProgress { Stage = "Generate", Message = x })), cancellationToken); await _word.ExportPdfAsync(result.OutputDocxPath, outputPdf, cancellationToken);
|
||||
var validation = Validate(result.OutputDocxPath, opened.Template.TemplateId); var validationPath = Path.ChangeExtension(request.OutputDocxPath, ".validation.json"); File.WriteAllText(validationPath, JsonSerializer.Serialize(validation, new JsonSerializerOptions { WriteIndented = true })); return new TemplateGenerationResult { OutputDocxPath = result.OutputDocxPath, OutputPdfPath = outputPdf, GenerationLogPath = result.LogPath, ValidationLogPath = validationPath, Validation = validation };
|
||||
}
|
||||
finally { TryDeleteDirectory(opened.WorkingDirectory); }
|
||||
}
|
||||
|
||||
private IReadOnlyList<RuleEvaluationResult> Validate(string output, string templateId)
|
||||
{
|
||||
var inspection = _inspector.Inspect(output); var result = new List<RuleEvaluationResult> { new() { RuleId = "DOCX_EXISTS", Status = File.Exists(output) ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, Message = "Output DOCX exists.", DocumentLocation = output }, new() { RuleId = "NO_BODY_MARKER", Status = inspection.InsertionPoint.Type == "None" ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.NeedsReview, Message = inspection.InsertionPoint.Type == "None" ? "No unresolved insertion marker remains." : "An insertion marker remains in the output.", DocumentLocation = "word/document.xml" }, new() { RuleId = "NO_TRACKED_CHANGES", Status = inspection.HasTrackedChanges ? RuleEvaluationStatus.Fail : RuleEvaluationStatus.Pass, Message = inspection.HasTrackedChanges ? "Tracked changes remain." : "No tracked changes detected." }, new() { RuleId = "TEMPLATE_ID", Status = RuleEvaluationStatus.Pass, Message = $"Generated from template {templateId}." } }; return result;
|
||||
}
|
||||
|
||||
private static void ReplaceScalarTokens(string path, IReadOnlyDictionary<string, string> values)
|
||||
{
|
||||
if (values.Count == 0) return; using var archive = ZipFile.Open(path, ZipArchiveMode.Update); var w = XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main");
|
||||
foreach (var entry in archive.Entries.Where(x => x.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase) && x.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)).ToArray())
|
||||
{
|
||||
XDocument document; using (var reader = new StreamReader(entry.Open())) document = XDocument.Parse(reader.ReadToEnd(), LoadOptions.PreserveWhitespace); var changed = false; foreach (var text in document.Descendants(w + "t")) { var value = text.Value; foreach (var pair in values) { var token = "{{" + pair.Key + "}}"; if (!value.Contains(token, StringComparison.OrdinalIgnoreCase)) continue; value = value.Replace(token, pair.Value, StringComparison.OrdinalIgnoreCase); changed = true; } text.Value = value; } if (!changed) continue; entry.Delete(); var replacement = archive.CreateEntry(entry.FullName, CompressionLevel.Optimal); using var stream = replacement.Open(); using var writer = new StreamWriter(stream, new UTF8Encoding(false)); document.Save(writer, SaveOptions.DisableFormatting);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, true); } catch { } }
|
||||
public void Dispose() { _word.Dispose(); if (_normalizer is IDisposable disposable) disposable.Dispose(); }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocxWorkbench.Core;
|
||||
|
||||
namespace DocxWorkbench.WordInterop;
|
||||
|
||||
public sealed class WordDocumentNormalizer : IWordDocumentNormalizer, IDisposable
|
||||
{
|
||||
private readonly StaWordQueue _queue;
|
||||
public WordDocumentNormalizer() : this(new StaWordQueue(), true) { }
|
||||
internal WordDocumentNormalizer(StaWordQueue queue) : this(queue, false) { }
|
||||
private readonly bool _ownsQueue;
|
||||
private WordDocumentNormalizer(StaWordQueue queue, bool ownsQueue) { _queue = queue; _ownsQueue = ownsQueue; }
|
||||
|
||||
public Task<DocumentNormalizationResult> NormalizeAsync(DocumentNormalizationRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken)
|
||||
=> _queue.Enqueue(() => NormalizeOnSta(request, progress, cancellationToken), cancellationToken);
|
||||
|
||||
private static DocumentNormalizationResult NormalizeOnSta(DocumentNormalizationRequest request, IProgress<WordOperationProgress>? progress, CancellationToken token)
|
||||
{
|
||||
if (!File.Exists(request.SourcePath)) throw new FileNotFoundException("Document source was not found.", request.SourcePath);
|
||||
var extension = Path.GetExtension(request.SourcePath);
|
||||
if (!extension.Equals(".doc", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".docx", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException("Template source must be DOC or DOCX.");
|
||||
var started = Stopwatch.StartNew(); var sourceHash = Hash(request.SourcePath); var work = Path.GetFullPath(request.WorkingDirectory); Directory.CreateDirectory(work); var normalized = Path.Combine(work, "normalized.docx"); token.ThrowIfCancellationRequested();
|
||||
if (extension.Equals(".docx", StringComparison.OrdinalIgnoreCase)) { File.Copy(request.SourcePath, normalized, true); return new DocumentNormalizationResult { SourcePath = request.SourcePath, NormalizedPath = normalized, SourceSha256 = sourceHash, NormalizedSha256 = Hash(normalized), WasConverted = false, Duration = started.Elapsed }; }
|
||||
dynamic? application = null; dynamic? documents = null; dynamic? document = null; var proxies = new List<object?>();
|
||||
try
|
||||
{
|
||||
progress?.Report(new WordOperationProgress { Stage = "NormalizeDoc", Message = "Opening binary DOC read-only" }); var wordType = Type.GetTypeFromProgID("Word.Application", false) ?? throw new WordNotInstalledException("Microsoft Word is not installed or is not registered in COM."); application = Own(Activator.CreateInstance(wordType), proxies) ?? throw new WordAutomationException("Could not create Word.Application."); application.Visible = false; application.DisplayAlerts = 0; if (request.DisableMacros) { try { application.AutomationSecurity = 3; } catch { } } documents = Own(application.Documents, proxies); document = Own(documents.Open(request.SourcePath, false, true, false), proxies) ?? throw new WordAutomationException("Word could not open the DOC source read-only."); token.ThrowIfCancellationRequested(); progress?.Report(new WordOperationProgress { Stage = "NormalizeDoc", Message = "Saving temporary DOCX" }); document.SaveAs2(normalized, 12); int? pageCount = null; try { document.Repaginate(); pageCount = (int)document.ComputeStatistics(2); } catch { } document.Close(0); document = null; return new DocumentNormalizationResult { SourcePath = request.SourcePath, NormalizedPath = normalized, SourceSha256 = sourceHash, NormalizedSha256 = Hash(normalized), WasConverted = true, Duration = started.Elapsed, PageCount = pageCount };
|
||||
}
|
||||
catch (WordNotInstalledException) { throw; }
|
||||
catch (WordAutomationException) { throw; }
|
||||
catch (Exception ex) { throw new WordAutomationException("DOC normalization failed.", ex); }
|
||||
finally { try { if (document is not null) document.Close(0); } catch { } try { if (application is not null) application.Quit(0); } catch { } ReleaseAll(proxies); }
|
||||
}
|
||||
|
||||
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
|
||||
private static T? Own<T>(T? value, List<object?> proxies) where T : class { if (value is not null) proxies.Add(value); return value; }
|
||||
private static void ReleaseAll(IEnumerable<object?> proxies) { foreach (var value in proxies.Reverse()) try { if (value is not null && Marshal.IsComObject(value)) Marshal.FinalReleaseComObject(value); } catch { } }
|
||||
public void Dispose() { if (_ownsQueue) _queue.Dispose(); }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -40,7 +41,7 @@ public sealed class WordDocumentService : IWordDocumentService, IDisposable
|
||||
range = Own(FindInsertionRange(document, insertion.Type, proxies), proxies) ?? throw new WordAutomationException("The insertion point disappeared while opening the template.");
|
||||
token.ThrowIfCancellationRequested(); progress?.Report($"Inserting {Path.GetExtension(request.BodyPath).ToUpperInvariant()} body"); if (request.BodyPath.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) range.Text = File.ReadAllText(request.BodyPath).Replace("\r\n", "\r").Replace('\n', '\r'); else bodyImport = _bodyImporter.ImportOnSta(application, document, range, new DocxBodyImportRequest { SourcePath = request.BodyPath, DestinationRangeHandle = range }, progress is null ? null : new Progress<WordOperationProgress>(x => progress.Report(x.Message ?? x.Stage)), token, proxies);
|
||||
token.ThrowIfCancellationRequested(); progress?.Report("Updating fields and pagination"); document.Repaginate(); document.Fields.Update(); UpdateHeadersFooters(document, proxies); document.SaveAs2(stagedOutput, 12); document.Close(0); document = null; RemoveUnreferencedHeaderFooterParts(stagedOutput); CommitOutput(stagedOutput, request.OutputDocxPath);
|
||||
var logPath = Path.ChangeExtension(request.OutputDocxPath, ".generation.json"); File.WriteAllText(logPath, JsonSerializer.Serialize(new { operationId = Guid.NewGuid(), template = request.TemplatePath, body = request.BodyPath, output = request.OutputDocxPath, insertionPoint = insertion.Type, wordPid, staThread = Thread.CurrentThread.ManagedThreadId, staApartment = Thread.CurrentThread.GetApartmentState().ToString(), durationMs = started.ElapsedMilliseconds, completedAtUtc = DateTime.UtcNow }, new JsonSerializerOptions { WriteIndented = true }));
|
||||
var logPath = Path.ChangeExtension(request.OutputDocxPath, ".generation.json"); File.WriteAllText(logPath, JsonSerializer.Serialize(new { operationId = Guid.NewGuid(), operationType = "BuildDocument", startedAtUtc = DateTime.UtcNow.Subtract(started.Elapsed), completedAtUtc = DateTime.UtcNow, template = request.TemplatePath, templateSha256 = Hash(request.TemplatePath), templateVersion = "1.0", body = request.BodyPath, sourceSha256 = Hash(request.BodyPath), output = request.OutputDocxPath, resultSha256 = Hash(request.OutputDocxPath), insertionPoint = insertion.Type, wordPid, staThread = Thread.CurrentThread.ManagedThreadId, staApartment = Thread.CurrentThread.GetApartmentState().ToString(), durationMs = started.ElapsedMilliseconds, status = "Succeeded", errorCode = (string?)null }, new JsonSerializerOptions { WriteIndented = true }));
|
||||
return new WordDocumentResult { OutputDocxPath = request.OutputDocxPath, LogPath = logPath, Duration = started.Elapsed, InsertionPoint = insertion.Type, BodyImport = bodyImport };
|
||||
}
|
||||
catch (WordNotInstalledException) { throw; }
|
||||
@@ -81,6 +82,7 @@ public sealed class WordDocumentService : IWordDocumentService, IDisposable
|
||||
private static int? TryGetPid(dynamic application) { try { return Process.GetProcessById((int)application.Hwnd).Id; } catch { return null; } }
|
||||
private static T? Own<T>(T? value, List<object?> proxies) where T : class { if (value is not null) proxies.Add(value); return value; }
|
||||
private static void ReleaseAll(IEnumerable<object?> proxies) { foreach (var proxy in proxies.Reverse()) try { if (proxy is not null && Marshal.IsComObject(proxy)) Marshal.FinalReleaseComObject(proxy); } catch { } }
|
||||
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
|
||||
private static void CommitOutput(string staged, string output) { var full = Path.GetFullPath(output); if (File.Exists(full)) File.Copy(full, full + ".bak-" + DateTime.UtcNow.ToString("yyyyMMddHHmmss"), true); File.Copy(staged, full, true); }
|
||||
private static void RemoveUnreferencedHeaderFooterParts(string path)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user