using System.Runtime.InteropServices; using System.IO.Compression; using System.Text; using System.Xml.Linq; using DocumentAutomation.Shared; using DocxWorkbench.Core; namespace DocxWorkbench.WordInterop; public sealed class DocxBodyImporter : IDocxBodyImporter, IDisposable { private readonly StaWordQueue _queue; private readonly bool _ownsQueue; public DocxBodyImporter() : this(new StaWordQueue(), true) { } internal DocxBodyImporter(StaWordQueue queue) : this(queue, false) { } private DocxBodyImporter(StaWordQueue queue, bool ownsQueue) { _queue = queue; _ownsQueue = ownsQueue; } public Task ImportAsync(DocxBodyImportRequest request, IProgress? progress, CancellationToken cancellationToken) => _queue.Enqueue(() => ImportStandalone(request, progress, cancellationToken), cancellationToken); private BodyImportResult ImportStandalone(DocxBodyImportRequest request, IProgress? progress, CancellationToken token) { dynamic? application = null; var proxies = new List(); try { var type = Type.GetTypeFromProgID("Word.Application", false) ?? throw new WordNotInstalledException("Microsoft Word is not installed or is not registered in COM."); application = Activator.CreateInstance(type) ?? throw new WordAutomationException("Could not create Word.Application."); application.Visible = false; application.DisplayAlerts = 0; return ImportOnSta(application, null, request.DestinationRangeHandle, request, progress, token, proxies); } finally { try { if (application is not null) application.Quit(0); } catch { } Release(application); ReleaseAll(proxies); } } internal BodyImportResult ImportOnSta(dynamic application, dynamic destinationDocument, dynamic destinationRange, DocxBodyImportRequest request, IProgress? progress, CancellationToken token, List proxies) { if (!File.Exists(request.SourcePath)) throw new FileNotFoundException("Source DOCX was not found.", request.SourcePath); if (!request.SourcePath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException("DocxBodyImporter accepts DOCX only."); var plan = BuildPlanFromOpenXml(request.SourcePath); var temp = Path.Combine(Path.GetTempPath(), "DocumentAutomation", "body-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temp); var sourceCopy = Path.Combine(temp, "source.docx"); File.Copy(request.SourcePath, sourceCopy, true); NormalizeSourceSections(sourceCopy, request.SectionBreakPolicy); dynamic? sourceDocument = null; dynamic? sourceDocuments = null; dynamic? sourceRange = null; dynamic? importRange = null; var localProxies = new List(); try { token.ThrowIfCancellationRequested(); progress?.Report(new WordOperationProgress { Stage = "OpenSource", Message = "Opening source DOCX read-only" }); sourceDocuments = Own(application.Documents, localProxies); sourceDocument = Own(sourceDocuments.Open(sourceCopy, true, false, false), localProxies); dynamic stories = Own(sourceDocument.StoryRanges, localProxies); sourceRange = Own(stories[1], localProxies); var start = (int)sourceRange.Start; var end = (int)sourceRange.End; if (end > start) end -= 1; importRange = Own(sourceDocument.Range(start, end), localProxies); var warnings = new List(); var sourceRevisions = TryCount(importRange.Revisions); var sourceComments = TryCount(importRange.Comments); if (sourceRevisions > 0) { if (request.RevisionPolicy == RevisionImportPolicy.BlockImport) throw new WordAutomationException("Source DOCX contains tracked changes and RevisionPolicy is BlockImport."); if (request.RevisionPolicy == RevisionImportPolicy.AcceptAll) importRange.Revisions.AcceptAll(); if (request.RevisionPolicy == RevisionImportPolicy.RejectAll) importRange.Revisions.RejectAll(); if (request.RevisionPolicy == RevisionImportPolicy.PreserveAndWarn) warnings.Add($"Tracked changes preserved: {sourceRevisions}."); } if (sourceComments > 0) { if (request.CommentPolicy == CommentImportPolicy.BlockImport) throw new WordAutomationException("Source DOCX contains comments and CommentPolicy is BlockImport."); if (request.CommentPolicy == CommentImportPolicy.PreserveAndWarn) warnings.Add($"Comments preserved: {sourceComments}."); } progress?.Report(new WordOperationProgress { Stage = "FormattedText", Message = "Copying only the main text story" }); destinationRange.FormattedText = importRange.FormattedText; if (request.CommentPolicy == CommentImportPolicy.Exclude) RemoveImportedComments(destinationRange); if (request.RevisionPolicy == RevisionImportPolicy.PreserveAndWarn) warnings.Add("Revision policy: PreserveAndWarn."); plan = plan with { Warnings = warnings }; return new BodyImportResult { Plan = plan, InsertionMode = "FormattedText", Warnings = warnings }; } finally { try { if (sourceDocument is not null) sourceDocument.Close(0); } catch { } ReleaseAll(localProxies); TryDelete(temp); } } private static BodyImportPlan BuildPlanFromOpenXml(string path) { using var archive = ZipFile.OpenRead(path); var entry = archive.GetEntry("word/document.xml") ?? throw new InvalidDataException("Source DOCX document.xml is missing."); using var reader = new StreamReader(entry.Open()); var document = XDocument.Parse(reader.ReadToEnd(), LoadOptions.PreserveWhitespace); var w = XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main"); var wp = XNamespace.Get("http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"); var body = document.Root?.Element(w + "body") ?? throw new InvalidDataException("Source DOCX body is missing."); var sectionMarks = body.Descendants(w + "sectPr").ToList(); var internalSections = Math.Max(0, sectionMarks.Count - 1); var pageBreaks = body.Descendants(w + "br").Count(x => string.Equals((string?)x.Attribute(w + "type"), "page", StringComparison.OrdinalIgnoreCase)); return new BodyImportPlan { ParagraphCount = body.Descendants(w + "p").Count(), TableCount = body.Descendants(w + "tbl").Count(), InlineShapeCount = body.Descendants(wp + "inline").Count(), FloatingShapeCount = body.Descendants(wp + "anchor").Count(), InternalSectionBreakCount = internalSections, PageBreakCount = pageBreaks, IncludedStories = ["wdMainTextStory"], ExcludedStories = ["wdPrimaryHeaderStory", "wdFirstPageHeaderStory", "wdEvenPagesHeaderStory", "wdPrimaryFooterStory", "wdFirstPageFooterStory", "wdEvenPagesFooterStory"], Warnings = [] }; } private static void NormalizeSourceSections(string path, SectionBreakPolicy policy) { if (policy == SectionBreakPolicy.ConvertToParagraph) policy = SectionBreakPolicy.Remove; using var archive = ZipFile.Open(path, ZipArchiveMode.Update); var entry = archive.GetEntry("word/document.xml") ?? throw new InvalidDataException("Source DOCX document.xml is missing."); string xml; using (var reader = new StreamReader(entry.Open())) xml = reader.ReadToEnd(); var w = XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main"); var document = XDocument.Parse(xml, LoadOptions.PreserveWhitespace); var body = document.Root?.Element(w + "body") ?? throw new InvalidDataException("Source DOCX body is missing."); var sections = body.Descendants(w + "sectPr").ToList(); for (var i = 0; i < sections.Count; i++) { var section = sections[i]; var parent = section.Parent; var paragraph = parent?.Name == w + "pPr" ? parent.Parent : null; section.Remove(); if (i < sections.Count - 1 && policy == SectionBreakPolicy.ConvertToPageBreak && paragraph is not null) paragraph.Add(new XElement(w + "r", new XElement(w + "br", new XAttribute(w + "type", "page")))); } entry.Delete(); var replacement = archive.CreateEntry("word/document.xml", CompressionLevel.Optimal); using var stream = replacement.Open(); using var writer = new StreamWriter(stream, new UTF8Encoding(false)); document.Save(writer, SaveOptions.DisableFormatting); } private static void RemoveImportedComments(dynamic range) { try { dynamic comments = range.Comments; for (var i = comments.Count; i >= 1; i--) comments[i].Delete(); } catch { } } private static int TryCount(dynamic collection) { try { return (int)collection.Count; } catch { return 0; } } private static T? Own(T? value, List proxies) where T : class { if (value is not null) proxies.Add(value); return value; } private static void Release(object? value) { try { if (value is not null && Marshal.IsComObject(value)) Marshal.FinalReleaseComObject(value); } catch { } } private static void ReleaseAll(IEnumerable values) { foreach (var value in values.Reverse()) Release(value); } private static void TryDelete(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, true); } catch { } } public void Dispose() { if (_ownsQueue) _queue.Dispose(); } }