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; using System.Xml.Linq; using DocumentAutomation.Shared; using DocxWorkbench.Core; namespace DocxWorkbench.WordInterop; public sealed class WordNotInstalledException(string message) : InvalidOperationException(message); public sealed class WordAutomationException(string message, Exception? inner = null) : InvalidOperationException(message, inner); public sealed class WordDocumentService : IWordDocumentService, IDisposable { private readonly StaWordQueue _queue = new(); private readonly DocxBodyImporter _bodyImporter; public WordDocumentService() => _bodyImporter = new DocxBodyImporter(_queue); public Task BuildDocumentAsync(WordDocumentBuildRequest request, IProgress? progress, CancellationToken cancellationToken) => _queue.Enqueue(() => BuildOnSta(request, progress, cancellationToken), cancellationToken); public Task ExportPdfAsync(string docxPath, string outputPdfPath, CancellationToken cancellationToken) => _queue.Enqueue(() => ExportPdfOnSta(docxPath, outputPdfPath, cancellationToken), cancellationToken); private WordDocumentResult BuildOnSta(WordDocumentBuildRequest request, IProgress? progress, CancellationToken token) { DocumentInputValidation.Validate(request); if (!request.BodyPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) && !request.BodyPath.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException($"Unsupported body format: {Path.GetExtension(request.BodyPath)}. Use DOCX or TXT."); var insertion = TemplateInsertionPointReader.Find(request.TemplatePath); if (insertion.Type == "None") throw new WordAutomationException("No DOC_BODY insertion point was found. Add a DOC_BODY content control, DOC_BODY bookmark, or {{DOC_BODY}} marker."); var started = Stopwatch.StartNew(); var temp = Path.Combine(Path.GetTempPath(), "DocumentAutomation", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temp); var workingTemplate = Path.Combine(temp, "template.docx"); var stagedOutput = Path.Combine(temp, "result.docx"); File.Copy(request.TemplatePath, workingTemplate, true); dynamic? application = null; dynamic? documents = null; dynamic? document = null; dynamic? range = null; var proxies = new List(); int? wordPid = null; BodyImportResult? bodyImport = null; try { token.ThrowIfCancellationRequested(); progress?.Report("Checking Word.Application COM registration"); 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; TrySetAutomationSecurity(application); wordPid = TryGetPid(application); progress?.Report($"Word COM started (PID {wordPid?.ToString() ?? "unknown"})"); documents = Own(application.Documents, proxies); document = Own(documents.Open(workingTemplate, false, false, false), proxies) ?? throw new WordAutomationException("Word could not open the working template copy."); 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(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(), 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; } catch (WordAutomationException) { throw; } catch (Exception ex) when (ex is not OperationCanceledException) { throw new WordAutomationException("Word automation failed. See technical details in the operation log.", ex); } finally { try { if (document is not null) document.Close(0); } catch { } try { if (application is not null) application.Quit(0); } catch { } ReleaseAll(proxies); TryDeleteDirectory(temp); } } private static WordPreviewResult ExportPdfOnSta(string docxPath, string outputPdfPath, CancellationToken token) { if (!File.Exists(docxPath)) throw new FileNotFoundException("DOCX was not found.", docxPath); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPdfPath))!); var sw = Stopwatch.StartNew(); dynamic? application = null; dynamic? documents = null; dynamic? document = null; var proxies = new List(); try { token.ThrowIfCancellationRequested(); 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; TrySetAutomationSecurity(application); documents = Own(application.Documents, proxies); document = Own(documents.Open(docxPath, true, false, false), proxies) ?? throw new WordAutomationException("Word could not open the DOCX."); document.ExportAsFixedFormat(outputPdfPath, 17); return new WordPreviewResult { PdfPath = outputPdfPath, Duration = sw.Elapsed }; } catch (WordNotInstalledException) { throw; } catch (WordAutomationException) { throw; } catch (Exception ex) when (ex is not OperationCanceledException) { throw new WordAutomationException("PDF export through Word 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 dynamic? FindInsertionRange(dynamic document, string type, List proxies) { if (type == "ContentControl") { dynamic controls = Own(document.ContentControls, proxies); for (var i = 1; i <= controls.Count; i++) { dynamic control = Own(controls[i], proxies); if (string.Equals((string?)control.Tag, "DOC_BODY", StringComparison.OrdinalIgnoreCase) || string.Equals((string?)control.Title, "DOC_BODY", StringComparison.OrdinalIgnoreCase)) return control.Range; } } if (type == "Bookmark") return document.Bookmarks["DOC_BODY"].Range; dynamic search = Own(document.Content, proxies); search.Find.ClearFormatting(); search.Find.Text = "{{DOC_BODY}}"; if (search.Find.Execute()) { search.Text = ""; return search; } return null; } private static void UpdateHeadersFooters(dynamic document, List proxies) { dynamic sections = Own(document.Sections, proxies); for (var i = 1; i <= sections.Count; i++) { dynamic section = Own(sections[i], proxies); UpdateCollection(section.Headers, proxies); UpdateCollection(section.Footers, proxies); } void UpdateCollection(dynamic collection, List proxies) { dynamic owned = WordDocumentService.Own(collection, proxies); for (var i = 1; i <= owned.Count; i++) { dynamic item = WordDocumentService.Own(owned[i], proxies); dynamic range = WordDocumentService.Own(item.Range, proxies); range.Fields.Update(); } } } private static void TrySetAutomationSecurity(dynamic application) { try { application.AutomationSecurity = 3; application.ScreenUpdating = false; } catch { } } private static int? TryGetPid(dynamic application) { try { return Process.GetProcessById((int)application.Hwnd).Id; } catch { return null; } } private static T? Own(T? value, List proxies) where T : class { if (value is not null) proxies.Add(value); return value; } private static void ReleaseAll(IEnumerable 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) { using var archive = ZipFile.Open(path, ZipArchiveMode.Update); var documentEntry = archive.GetEntry("word/document.xml"); var relsEntry = archive.GetEntry("word/_rels/document.xml.rels"); if (documentEntry is null || relsEntry is null) return; XDocument document; XDocument relationships; using (var reader = new StreamReader(documentEntry.Open())) document = XDocument.Parse(reader.ReadToEnd(), LoadOptions.PreserveWhitespace); using (var reader = new StreamReader(relsEntry.Open())) relationships = XDocument.Parse(reader.ReadToEnd(), LoadOptions.PreserveWhitespace); var w = XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main"); var rel = XNamespace.Get("http://schemas.openxmlformats.org/package/2006/relationships"); var used = document.Descendants(w + "headerReference").Concat(document.Descendants(w + "footerReference")).Select(x => (string?)x.Attribute(XNamespace.Get("http://schemas.openxmlformats.org/officeDocument/2006/relationships") + "id")).Where(x => x is not null).ToHashSet(StringComparer.Ordinal); var removedTargets = new List(); foreach (var relationship in relationships.Root?.Elements(rel + "Relationship").ToList() ?? []) { var type = (string?)relationship.Attribute("Type") ?? string.Empty; var id = (string?)relationship.Attribute("Id"); if (id is null || (!type.EndsWith("/header", StringComparison.OrdinalIgnoreCase) && !type.EndsWith("/footer", StringComparison.OrdinalIgnoreCase)) || used.Contains(id)) continue; var target = (string?)relationship.Attribute("Target"); if (!string.IsNullOrWhiteSpace(target)) removedTargets.Add(ResolveWordPart(target)); relationship.Remove(); } foreach (var target in removedTargets.Distinct(StringComparer.OrdinalIgnoreCase)) archive.GetEntry(target)?.Delete(); relsEntry.Delete(); var replacement = archive.CreateEntry("word/_rels/document.xml.rels", CompressionLevel.Optimal); using var stream = replacement.Open(); using var writer = new StreamWriter(stream, new UTF8Encoding(false)); relationships.Save(writer, SaveOptions.DisableFormatting); } private static string ResolveWordPart(string target) => target.StartsWith("/", StringComparison.Ordinal) ? target.TrimStart('/') : "word/" + target.Replace("\\", "/").TrimStart("./".ToCharArray()); private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, true); } catch { } } public void Dispose() => _queue.Dispose(); } internal static class TemplateInsertionPointReader { public static InsertionPointInfo Find(string docxPath) { using var archive = ZipFile.OpenRead(docxPath); var entry = archive.GetEntry("word/document.xml") ?? throw new InvalidDataException("DOCX document.xml is missing."); using var reader = new StreamReader(entry.Open()); var xml = reader.ReadToEnd(); if (Regex.IsMatch(xml, @"w:tag[^>]+w:val=""DOC_BODY""", RegexOptions.IgnoreCase)) return new InsertionPointInfo { Type = "ContentControl", Location = "word/document.xml" }; if (Regex.IsMatch(xml, @"w:bookmarkStart[^>]+w:name=""DOC_BODY""", RegexOptions.IgnoreCase)) return new InsertionPointInfo { Type = "Bookmark", Location = "word/document.xml" }; if (xml.Contains("{{DOC_BODY}}", StringComparison.Ordinal)) return new InsertionPointInfo { Type = "TextMarker", Location = "word/document.xml" }; return new InsertionPointInfo { Type = "None" }; } }