fix: import DOCX body without source layout
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
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<BodyImportResult> ImportAsync(DocxBodyImportRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken)
|
||||
=> _queue.Enqueue(() => ImportStandalone(request, progress, cancellationToken), cancellationToken);
|
||||
|
||||
private BodyImportResult ImportStandalone(DocxBodyImportRequest request, IProgress<WordOperationProgress>? progress, CancellationToken token)
|
||||
{
|
||||
dynamic? application = null; var proxies = new List<object?>(); 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<WordOperationProgress>? progress, CancellationToken token, List<object?> 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<object?>();
|
||||
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<string>(); 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>(T? value, List<object?> 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<object?> 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(); }
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocxWorkbench.Core;
|
||||
|
||||
@@ -15,6 +16,8 @@ public sealed class WordAutomationException(string message, Exception? inner = n
|
||||
public sealed class WordDocumentService : IWordDocumentService, IDisposable
|
||||
{
|
||||
private readonly StaWordQueue _queue = new();
|
||||
private readonly DocxBodyImporter _bodyImporter;
|
||||
public WordDocumentService() => _bodyImporter = new DocxBodyImporter(_queue);
|
||||
|
||||
public Task<WordDocumentResult> BuildDocumentAsync(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken cancellationToken)
|
||||
=> _queue.Enqueue(() => BuildOnSta(request, progress, cancellationToken), cancellationToken);
|
||||
@@ -22,23 +25,23 @@ public sealed class WordDocumentService : IWordDocumentService, IDisposable
|
||||
public Task<WordPreviewResult> ExportPdfAsync(string docxPath, string outputPdfPath, CancellationToken cancellationToken)
|
||||
=> _queue.Enqueue(() => ExportPdfOnSta(docxPath, outputPdfPath, cancellationToken), cancellationToken);
|
||||
|
||||
private static WordDocumentResult BuildOnSta(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken token)
|
||||
private WordDocumentResult BuildOnSta(WordDocumentBuildRequest request, IProgress<string>? 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<object?>(); int? wordPid = null;
|
||||
dynamic? application = null; dynamic? documents = null; dynamic? document = null; dynamic? range = null; var proxies = new List<object?>(); 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 InsertDocxFile(range, request.BodyPath);
|
||||
token.ThrowIfCancellationRequested(); progress?.Report("Updating fields and pagination"); document.Repaginate(); document.Fields.Update(); UpdateHeadersFooters(document, proxies); document.SaveAs2(stagedOutput, 12); CommitOutput(stagedOutput, request.OutputDocxPath);
|
||||
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 }));
|
||||
return new WordDocumentResult { OutputDocxPath = request.OutputDocxPath, LogPath = logPath, Duration = started.Elapsed, InsertionPoint = insertion.Type };
|
||||
return new WordDocumentResult { OutputDocxPath = request.OutputDocxPath, LogPath = logPath, Duration = started.Elapsed, InsertionPoint = insertion.Type, BodyImport = bodyImport };
|
||||
}
|
||||
catch (WordNotInstalledException) { throw; }
|
||||
catch (WordAutomationException) { throw; }
|
||||
@@ -75,11 +78,41 @@ public sealed class WordDocumentService : IWordDocumentService, IDisposable
|
||||
}
|
||||
|
||||
private static void TrySetAutomationSecurity(dynamic application) { try { application.AutomationSecurity = 3; application.ScreenUpdating = false; } catch { } }
|
||||
private static void InsertDocxFile(dynamic range, string path) { range.GetType().InvokeMember("InsertFile", BindingFlags.InvokeMethod | BindingFlags.OptionalParamBinding, null, range, new object[] { path, Type.Missing, Type.Missing, Type.Missing, Type.Missing }); }
|
||||
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 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<string>();
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user