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 NormalizeAsync(DocumentNormalizationRequest request, IProgress? progress, CancellationToken cancellationToken) => _queue.Enqueue(() => NormalizeOnSta(request, progress, cancellationToken), cancellationToken); private static DocumentNormalizationResult NormalizeOnSta(DocumentNormalizationRequest request, IProgress? 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(); 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? value, List proxies) where T : class { if (value is not null) proxies.Add(value); return value; } private static void ReleaseAll(IEnumerable 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(); } }