fix: restore DOCX workbench runtime path

This commit is contained in:
codex
2026-07-30 14:00:31 +03:00
parent dab90f0780
commit 77a62d4564
13 changed files with 381 additions and 89 deletions
@@ -3,3 +3,5 @@
The preferred insertion point is a Content Control with tag or title `DOC_BODY`, then a bookmark named `DOC_BODY`, then the exact marker `{{DOC_BODY}}`. If none is present, generation fails explicitly; the body is never silently appended. The preferred insertion point is a Content Control with tag or title `DOC_BODY`, then a bookmark named `DOC_BODY`, then the exact marker `{{DOC_BODY}}`. If none is present, generation fails explicitly; the body is never silently appended.
The template is copied to a temporary working file. Body headers/footers are not imported. Template sections and headers/footers are retained, fields are updated, and Word repaginates before saving. The template is copied to a temporary working file. Body headers/footers are not imported. Template sections and headers/footers are retained, fields are updated, and Word repaginates before saving.
Before Word starts, the template is checked for the insertion point. If none is found, the build is rejected without starting Word. The GUI routes `.docx` through Open XML and `.txt` through `TextDocumentInspector`; template and body analysis results remain independent.
@@ -1,5 +1,5 @@
# Word COM lifecycle # Word COM lifecycle
Word is started in a dedicated task with `Visible=false` and alerts disabled. The service opens a working copy, performs one operation, updates fields, repaginates, saves, closes the Document, quits the Application, and releases owned COM objects in `finally`. Word is resolved late-bound through `Word.Application`; the Word PIA and `office.dll` are not required at application load time. Each service owns one serial `StaWordQueue`, and every Word job runs on its dedicated STA thread with `Visible=false` and alerts disabled. The service opens a working copy, performs one operation, updates fields, repaginates, saves, closes the Document, quits the Application, and releases owned COM proxies in `finally`.
The prototype does not run as a Windows Service, does not kill unrelated `WINWORD.EXE` processes, and does not execute macros. Production hardening should add an explicit serial queue and PID-scoped orphan check. The prototype does not run as a Windows Service, does not kill unrelated `WINWORD.EXE` processes, and does not execute macros. Integration tests compare process IDs before and after each job and verify that no newly-created Word process remains.
@@ -2,6 +2,57 @@ using System.Text.Json.Serialization;
namespace DocumentAutomation.Shared; namespace DocumentAutomation.Shared;
public sealed record InspectionWarning
{
public required string Code { get; init; }
public required string Message { get; init; }
public string? Part { get; init; }
public string? Location { get; init; }
public string? MatchedValue { get; init; }
public string? DetectionRule { get; init; }
}
public sealed record DocumentFieldInfo
{
public required string Type { get; init; }
public required string Part { get; init; }
public int? Section { get; init; }
public string? Instruction { get; init; }
}
public sealed record DocumentImageSummary
{
public int RasterImages { get; init; }
public int DrawingObjects { get; init; }
public int VmlShapes { get; init; }
public int ExternalImages { get; init; }
}
public sealed record DocumentStyleInfo
{
public required string StyleId { get; init; }
public string? Name { get; init; }
public string? Type { get; init; }
public bool IsDefault { get; init; }
public string? BasedOn { get; init; }
public string? LinkedStyle { get; init; }
public int UsageCount { get; init; }
}
public sealed record SectionPartAssignment
{
public required int Section { get; init; }
public IReadOnlyList<string> Headers { get; init; } = [];
public IReadOnlyList<string> Footers { get; init; } = [];
public bool LinkToPrevious { get; init; }
}
public sealed record InsertionPointInfo
{
public required string Type { get; init; }
public string? Location { get; init; }
}
public sealed record DocumentInspectionResult public sealed record DocumentInspectionResult
{ {
public required string FilePath { get; init; } public required string FilePath { get; init; }
@@ -11,18 +62,23 @@ public sealed record DocumentInspectionResult
public int Paragraphs { get; init; } public int Paragraphs { get; init; }
public int Tables { get; init; } public int Tables { get; init; }
public int Images { get; init; } public int Images { get; init; }
public DocumentImageSummary ImageSummary { get; init; } = new();
public int Headers { get; init; } public int Headers { get; init; }
public int Footers { get; init; } public int Footers { get; init; }
public bool HasPageFields { get; init; } public bool HasPageFields { get; init; }
public bool HasNumPagesFields { get; init; } public bool HasNumPagesFields { get; init; }
public bool HasSectionPagesFields { get; init; } public bool HasSectionPagesFields { get; init; }
public IReadOnlyList<DocumentFieldInfo> Fields { get; init; } = [];
public int Bookmarks { get; init; } public int Bookmarks { get; init; }
public int ContentControls { get; init; } public int ContentControls { get; init; }
public int Comments { get; init; } public int Comments { get; init; }
public bool HasTrackedChanges { get; init; } public bool HasTrackedChanges { get; init; }
public bool HasProtectedParts { get; init; } public bool HasProtectedParts { get; init; }
public IReadOnlyList<string> Styles { get; init; } = []; public IReadOnlyList<DocumentStyleInfo> StyleDetails { get; init; } = [];
public IReadOnlyList<string> Warnings { get; init; } = []; public IReadOnlyList<SectionPartAssignment> SectionAssignments { get; init; } = [];
public InsertionPointInfo InsertionPoint { get; init; } = new() { Type = "None" };
public IReadOnlyList<InspectionWarning> Warnings { get; init; } = [];
public IReadOnlyList<string> Styles => StyleDetails.Select(x => x.StyleId).ToArray();
} }
public sealed record WordDocumentBuildRequest public sealed record WordDocumentBuildRequest
@@ -1,5 +1,4 @@
using System.Diagnostics; using System.Diagnostics;
using System.Text;
using System.Text.Json; using System.Text.Json;
using DocumentAutomation.Shared; using DocumentAutomation.Shared;
using DocxWorkbench.Core; using DocxWorkbench.Core;
@@ -10,24 +9,47 @@ namespace DocxWorkbench.App;
public sealed class MainForm : Form public sealed class MainForm : Form
{ {
private readonly TextBox _template = new() { Width = 520 }; private readonly TextBox _body = new() { Width = 520 }; private readonly TextBox _output = new() { Width = 520 }; private readonly TextBox _log = new() { Multiline = true, ScrollBars = ScrollBars.Vertical, Dock = DockStyle.Fill }; private readonly Label _status = new() { AutoSize = true, Text = "Готово" }; private readonly IDocumentInspector _inspector = new DocumentInspector(); private readonly IWordDocumentService _word = new WordDocumentService(); private readonly TextBox _template = new() { Width = 520 }; private readonly TextBox _body = new() { Width = 520 }; private readonly TextBox _output = new() { Width = 520 }; private readonly RichTextBox _structure = new() { Dock = DockStyle.Fill, ReadOnly = true }; private readonly RichTextBox _bodyPreview = new() { Dock = DockStyle.Fill, ReadOnly = true }; private readonly TextBox _log = new() { Multiline = true, ScrollBars = ScrollBars.Vertical, Dock = DockStyle.Fill, ReadOnly = true }; private readonly TextBox _technicalError = new() { Multiline = true, ScrollBars = ScrollBars.Vertical, Dock = DockStyle.Fill, ReadOnly = true };
private readonly Label _status = new() { AutoSize = true, Text = "Ready" }; private readonly Button _analyze; private readonly Button _build; private readonly Button _validate; private readonly Button _openDocx; private readonly Button _openPdf; private readonly WorkbenchStateController _state = new(); private readonly IDocumentInspector _docx = new OpenXmlDocumentInspector(); private readonly IDocumentInspector _txt = new TextDocumentInspector(); private readonly IWordDocumentService _word = new WordDocumentService(); private DocumentInspectionResult? _templateResult; private DocumentInspectionResult? _bodyResult; private string? _builtDocx; private string? _builtPdf;
public MainForm() public MainForm()
{ {
Text = "DOCX Workbench"; Width = 980; Height = 680; StartPosition = FormStartPosition.CenterScreen; Text = "DOCX Workbench"; Width = 1100; Height = 720; StartPosition = FormStartPosition.CenterScreen; var grid = new TableLayoutPanel { Dock = DockStyle.Top, Height = 145, ColumnCount = 3, RowCount = 4, Padding = new Padding(8) }; grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 150)); grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 110)); AddRow(grid, 0, "Template DOCX", _template, BrowseTemplate); AddRow(grid, 1, "Document body", _body, BrowseBody); AddRow(grid, 2, "Output DOCX", _output, BrowseOutput);
var grid = new TableLayoutPanel { Dock = DockStyle.Top, Height = 145, ColumnCount = 3, RowCount = 4, Padding = new Padding(8) }; grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 150)); grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 110)); var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill }; _analyze = Button("Analyze", async (_, _) => await AnalyzeAsync()); _build = Button("Build", async (_, _) => await BuildAsync()); _validate = Button("Validate", async (_, _) => await ValidateAsync()); _openDocx = Button("Open DOCX", (_, _) => Open(_builtDocx)); _openPdf = Button("Open PDF", (_, _) => Open(_builtPdf)); buttons.Controls.AddRange([_analyze, _build, _validate, _openDocx, _openPdf]); grid.Controls.Add(buttons, 1, 3); grid.SetColumnSpan(buttons, 2);
AddRow(grid, 0, "Шаблон DOCX", _template, BrowseTemplate); AddRow(grid, 1, "Тело документа", _body, BrowseBody); AddRow(grid, 2, "Выходной DOCX", _output, BrowseOutput); var tabs = new TabControl { Dock = DockStyle.Fill }; AddTab(tabs, "Template structure", _structure); AddTab(tabs, "Body preview", _bodyPreview); AddTab(tabs, "Result", new Label { Text = "The result becomes available after a successful build.", AutoSize = true }); AddTab(tabs, "Violations", new Label { Text = "No validation has been run.", AutoSize = true }); AddTab(tabs, "Technical error", _technicalError); AddTab(tabs, "Log", _log); Controls.Add(tabs); Controls.Add(grid); Controls.Add(_status); _status.Dock = DockStyle.Bottom;
var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill }; buttons.Controls.Add(Button("Анализировать", async (_, _) => await InspectAsync())); buttons.Controls.Add(Button("Сформировать", async (_, _) => await BuildAsync())); buttons.Controls.Add(Button("Проверить", async (_, _) => await InspectOutputAsync())); buttons.Controls.Add(Button("Открыть DOCX", (_, _) => Open(_output.Text))); buttons.Controls.Add(Button("Открыть PDF", (_, _) => Open(Path.ChangeExtension(_output.Text, ".pdf")))); grid.Controls.Add(buttons, 1, 3); grid.SetColumnSpan(buttons, 2); foreach (var box in new[] { _template, _body, _output }) box.TextChanged += (_, _) => InputsChanged(); RefreshButtons();
var tabs = new TabControl { Dock = DockStyle.Fill }; foreach (var title in new[] { "Предпросмотр шаблона", "Предпросмотр тела", "Структура", "Результат", "Нарушения", "Журнал" }) { var page = new TabPage(title); if (title == "Журнал") page.Controls.Add(_log); tabs.TabPages.Add(page); }
Controls.Add(tabs); Controls.Add(grid); Controls.Add(_status); _status.Dock = DockStyle.Bottom;
} }
private static void AddRow(TableLayoutPanel p, int row, string title, TextBox box, EventHandler browse) { p.Controls.Add(new Label { Text = title, AutoSize = true, Anchor = AnchorStyles.Left }, 0, row); p.Controls.Add(box, 1, row); var b = new Button { Text = "Обзор...", Width = 95 }; b.Click += browse; p.Controls.Add(b, 2, row); }
private static Button Button(string text, EventHandler handler) { var b = new Button { Text = text, AutoSize = true }; b.Click += handler; return b; } private static void AddTab(TabControl tabs, string title, Control content) { var tab = new TabPage(title); tab.Controls.Add(content); tabs.TabPages.Add(tab); }
private void BrowseTemplate(object? s, EventArgs e) { using var d = new OpenFileDialog { Filter = "DOCX|*.docx" }; if (d.ShowDialog() == DialogResult.OK) _template.Text = d.FileName; } private static void AddRow(TableLayoutPanel panel, int row, string title, TextBox box, EventHandler browse) { panel.Controls.Add(new Label { Text = title, AutoSize = true, Anchor = AnchorStyles.Left }, 0, row); panel.Controls.Add(box, 1, row); var button = new Button { Text = "Browse...", Width = 95 }; button.Click += browse; panel.Controls.Add(button, 2, row); }
private void BrowseBody(object? s, EventArgs e) { using var d = new OpenFileDialog { Filter = "DOCX/TXT|*.docx;*.txt" }; if (d.ShowDialog() == DialogResult.OK) _body.Text = d.FileName; } private static Button Button(string text, EventHandler handler) { var button = new Button { Text = text, AutoSize = true }; button.Click += handler; return button; }
private void BrowseOutput(object? s, EventArgs e) { using var d = new SaveFileDialog { Filter = "DOCX|*.docx", DefaultExt = "docx" }; if (d.ShowDialog() == DialogResult.OK) _output.Text = d.FileName; } private void BrowseTemplate(object? sender, EventArgs e) { using var dialog = new OpenFileDialog { Filter = "DOCX|*.docx" }; if (dialog.ShowDialog() == DialogResult.OK) _template.Text = dialog.FileName; }
private async Task InspectAsync() => await RunAsync("Анализ", () => Task.Run(() => { var a = _inspector.Inspect(_template.Text); var b = _inspector.Inspect(_body.Text); return (object)new { Template = a, Body = b }; })); private void BrowseBody(object? sender, EventArgs e) { using var dialog = new OpenFileDialog { Filter = "DOCX or TXT|*.docx;*.txt" }; if (dialog.ShowDialog() == DialogResult.OK) _body.Text = dialog.FileName; }
private async Task InspectOutputAsync() => await RunAsync("Проверка результата", () => Task.Run(() => (object)_inspector.Inspect(_output.Text))); private void BrowseOutput(object? sender, EventArgs e) { using var dialog = new SaveFileDialog { Filter = "DOCX|*.docx", DefaultExt = "docx" }; if (dialog.ShowDialog() == DialogResult.OK) _output.Text = dialog.FileName; }
private async Task BuildAsync() => await RunAsync("Формирование", async () => { var result = await _word.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = _template.Text, BodyPath = _body.Text, OutputDocxPath = _output.Text }, new Progress<string>(x => _status.Text = x), CancellationToken.None); var pdf = await _word.ExportPdfAsync(result.OutputDocxPath, Path.ChangeExtension(result.OutputDocxPath, ".pdf"), CancellationToken.None); return (object)new { result, pdf }; });
private async Task RunAsync(string name, Func<Task<object>> action) { try { _status.Text = name + "..."; var value = await action(); _log.AppendText(JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true }) + Environment.NewLine); _status.Text = name + ": готово"; } catch (Exception ex) { _log.AppendText($"{name}: {ex}\r\n"); _status.Text = name + ": ошибка"; MessageBox.Show(ex.Message, name, MessageBoxButtons.OK, MessageBoxIcon.Error); } } private async Task AnalyzeAsync()
private static void Open(string path) { if (File.Exists(path)) Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); } {
_templateResult = null; _bodyResult = null; _builtDocx = null; _builtPdf = null; _state.InputsSelected(File.Exists(_template.Text) && File.Exists(_body.Text)); RefreshButtons(); _status.Text = "Analyzing...";
var template = await InspectPathAsync(_template.Text); var body = await InspectPathAsync(_body.Text); if (template.Error is null) { _templateResult = template.Result; ShowStructure(template.Result); } if (body.Error is null) { _bodyResult = body.Result; ShowBody(body.Result, _body.Text); } LogInspection("Template", _template.Text, template); LogInspection("Body", _body.Text, body); var insertion = _templateResult?.InsertionPoint.Type != "None"; _state.AnalysisCompleted(_templateResult is not null, _bodyResult is not null, insertion); RefreshButtons(); _status.Text = _state.State == WorkbenchState.ReadyToBuild ? "Ready to build" : "Analysis completed with errors"; if (!insertion && _templateResult is not null) AppendLog("No DOC_BODY insertion point found. Word was not started.");
}
private Task<(DocumentInspectionResult? Result, Exception? Error, string Inspector)> InspectPathAsync(string path) => Task.Run(() => { try { if (path.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) return (_docx.Inspect(path), (Exception?)null, "OpenXmlDocumentInspector"); if (path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) return (_txt.Inspect(path), (Exception?)null, "TextDocumentInspector"); throw new UnsupportedDocumentFormatException($"Unsupported format {Path.GetExtension(path)}. Choose DOCX or TXT."); } catch (Exception ex) { return ((DocumentInspectionResult?)null, ex, path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? "TextDocumentInspector" : "OpenXmlDocumentInspector"); } });
private void ShowStructure(DocumentInspectionResult? result) { if (result is null) return; _structure.Text = JsonSerializer.Serialize(new { result.FilePath, result.Sections, result.Paragraphs, result.Tables, result.Headers, result.Footers, result.Fields, result.ImageSummary, result.SectionAssignments, result.InsertionPoint, result.StyleDetails, result.Warnings }, new JsonSerializerOptions { WriteIndented = true }); }
private void ShowBody(DocumentInspectionResult? result, string path) { if (path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) _bodyPreview.Text = File.ReadAllText(path); else if (result is not null) _bodyPreview.Text = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); }
private void LogInspection(string label, string path, (DocumentInspectionResult? Result, Exception? Error, string Inspector) value) { AppendLog(JsonSerializer.Serialize(new { label, path, inspector = value.Inspector, result = value.Result, error = value.Error?.Message }, new JsonSerializerOptions { WriteIndented = true })); if (value.Error is not null) _technicalError.Text = value.Error.ToString(); }
private async Task BuildAsync()
{
if (!_state.CanBuild) return; _state.BeginBuild(); RefreshButtons(); _status.Text = "Building..."; try { var result = await _word.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = _template.Text, BodyPath = _body.Text, OutputDocxPath = _output.Text }, new Progress<string>(x => _status.Text = x), CancellationToken.None); var pdf = await _word.ExportPdfAsync(result.OutputDocxPath, Path.ChangeExtension(result.OutputDocxPath, ".pdf"), CancellationToken.None); _builtDocx = result.OutputDocxPath; _builtPdf = pdf.PdfPath; _state.BuildSucceeded(); AppendLog(JsonSerializer.Serialize(new { result, pdf }, new JsonSerializerOptions { WriteIndented = true })); _status.Text = "Build succeeded"; } catch (Exception ex) { _state.BuildFailed(); _technicalError.Text = ex.ToString(); AppendLog("Build failed: " + ex.Message); _status.Text = "Build failed"; MessageBox.Show(ex.Message, "Build failed", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { RefreshButtons(); }
}
private async Task ValidateAsync()
{
if (!_state.CanValidate || _builtDocx is null || !File.Exists(_builtDocx)) return; _state.BeginValidation(); RefreshButtons(); _status.Text = "Validating..."; try { var result = await Task.Run(() => _docx.Inspect(_builtDocx)); _state.ValidationCompleted(true); AppendLog(JsonSerializer.Serialize(new { label = "Output", result }, new JsonSerializerOptions { WriteIndented = true })); _status.Text = "Validation succeeded"; } catch (Exception ex) { _state.ValidationCompleted(false); _technicalError.Text = ex.ToString(); AppendLog("Validation failed: " + ex.Message); _status.Text = "Validation failed"; MessageBox.Show(ex.Message, "Validation failed", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { RefreshButtons(); }
}
private void InputsChanged() { _templateResult = null; _bodyResult = null; _builtDocx = null; _builtPdf = null; _state.InputsSelected(File.Exists(_template.Text) && File.Exists(_body.Text)); RefreshButtons(); }
private void RefreshButtons() { _analyze.Enabled = File.Exists(_template.Text) && File.Exists(_body.Text); _build.Enabled = _state.CanBuild; _validate.Enabled = _state.CanValidate && _builtDocx is not null && File.Exists(_builtDocx); _openDocx.Enabled = _state.CanOpenDocx && _builtDocx is not null && File.Exists(_builtDocx); _openPdf.Enabled = _state.CanOpenPdf && _builtPdf is not null && File.Exists(_builtPdf); }
private void AppendLog(string text) => _log.AppendText(text + Environment.NewLine);
private static void Open(string? path) { if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); }
} }
@@ -7,6 +7,27 @@ public interface IDocumentInspector
DocumentInspectionResult Inspect(string docxPath); DocumentInspectionResult Inspect(string docxPath);
} }
public sealed class UnsupportedDocumentFormatException(string message) : InvalidOperationException(message);
public enum WorkbenchState { Empty, InputsSelected, Analyzed, ReadyToBuild, Building, BuildSucceeded, BuildFailed, Validating, ValidationSucceeded, ValidationFailed }
public sealed class WorkbenchStateController
{
public WorkbenchState State { get; private set; } = WorkbenchState.Empty;
public bool CanBuild => State == WorkbenchState.ReadyToBuild;
public bool CanValidate => State == WorkbenchState.BuildSucceeded;
public bool CanOpenDocx { get; private set; }
public bool CanOpenPdf { get; private set; }
public void InputsSelected(bool selected) { State = selected ? WorkbenchState.InputsSelected : WorkbenchState.Empty; if (!selected) ClearArtifacts(); }
public void AnalysisCompleted(bool templateOk, bool bodyOk, bool insertionPointFound) { State = templateOk && bodyOk && insertionPointFound ? WorkbenchState.ReadyToBuild : WorkbenchState.Analyzed; ClearArtifacts(); }
public void BeginBuild() { if (!CanBuild) throw new InvalidOperationException("Build is unavailable in the current state."); State = WorkbenchState.Building; ClearArtifacts(); }
public void BuildSucceeded() { State = WorkbenchState.BuildSucceeded; CanOpenDocx = true; CanOpenPdf = true; }
public void BuildFailed() { State = WorkbenchState.BuildFailed; ClearArtifacts(); }
public void BeginValidation() { if (!CanValidate) throw new InvalidOperationException("Validation is available only after a successful build."); State = WorkbenchState.Validating; }
public void ValidationCompleted(bool success) => State = success ? WorkbenchState.ValidationSucceeded : WorkbenchState.ValidationFailed;
private void ClearArtifacts() { CanOpenDocx = false; CanOpenPdf = false; }
}
public interface IWordDocumentService public interface IWordDocumentService
{ {
Task<WordDocumentResult> BuildDocumentAsync(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken cancellationToken); Task<WordDocumentResult> BuildDocumentAsync(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken cancellationToken);
@@ -1,39 +1,72 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using DocumentAutomation.Shared; using DocumentAutomation.Shared;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing; using DocumentFormat.OpenXml.Wordprocessing;
using DocxWorkbench.Core; using DocxWorkbench.Core;
namespace DocxWorkbench.OpenXml; namespace DocxWorkbench.OpenXml;
public sealed class DocumentInspector : IDocumentInspector public sealed class OpenXmlDocumentInspector : IDocumentInspector
{ {
private static readonly string[] KnownFields = ["PAGE", "NUMPAGES", "SECTIONPAGES", "FILENAME"];
public DocumentInspectionResult Inspect(string docxPath) public DocumentInspectionResult Inspect(string docxPath)
{ {
if (!File.Exists(docxPath)) throw new FileNotFoundException("DOCX не найден.", docxPath); if (!File.Exists(docxPath)) throw new FileNotFoundException("DOCX was not found.", docxPath);
var info = new FileInfo(docxPath); if (!docxPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException($"Open XML inspector accepts DOCX only, received {Path.GetExtension(docxPath)}.");
using var package = WordprocessingDocument.Open(docxPath, false); var info = new FileInfo(docxPath); using var package = WordprocessingDocument.Open(docxPath, false); var main = package.MainDocumentPart ?? throw new InvalidDataException("The DOCX has no main document part."); var roots = GetRoots(main); var fields = roots.SelectMany(x => FindFields(x.Root, x.Part, x.Section)).ToArray(); var images = CountImages(main, roots);
var main = package.MainDocumentPart ?? throw new InvalidDataException("В DOCX отсутствует основной документ.");
var body = main.Document.Body;
var text = main.Document.InnerText;
var headers = main.HeaderParts.Count();
var footers = main.FooterParts.Count();
var warnings = new List<string>();
if (Regex.IsMatch(text, "2019|старый|legacy", RegexOptions.IgnoreCase)) warnings.Add("Обнаружены возможные старые проектные реквизиты; проверьте вручную.");
if (main.Document.Descendants<SectionProperties>().Any(s => s.GetFirstChild<HeaderReference>() is null)) warnings.Add("Есть секция без явной ссылки на header.");
return new DocumentInspectionResult return new DocumentInspectionResult
{ {
FilePath = docxPath, FileSize = info.Length, LastWriteTimeUtc = info.LastWriteTimeUtc, FilePath = docxPath, FileSize = info.Length, LastWriteTimeUtc = info.LastWriteTimeUtc, Sections = Math.Max(1, main.Document.Descendants<SectionProperties>().Count()), Paragraphs = main.Document.Descendants<Paragraph>().Count(), Tables = main.Document.Descendants<Table>().Count(), Images = images.RasterImages, ImageSummary = images,
Sections = Math.Max(1, main.Document.Descendants<SectionProperties>().Count()), Headers = main.HeaderParts.Count(), Footers = main.FooterParts.Count(), HasPageFields = fields.Any(x => x.Type == "PAGE"), HasNumPagesFields = fields.Any(x => x.Type == "NUMPAGES"), HasSectionPagesFields = fields.Any(x => x.Type == "SECTIONPAGES"), Fields = fields,
Paragraphs = main.Document.Descendants<Paragraph>().Count(), Tables = main.Document.Descendants<Table>().Count(), Bookmarks = main.Document.Descendants<BookmarkStart>().Count(), ContentControls = main.Document.Descendants<SdtElement>().Count(), Comments = main.GetPartsOfType<WordprocessingCommentsPart>().Count(), HasTrackedChanges = main.Document.Descendants<Inserted>().Any() || main.Document.Descendants<Deleted>().Any(), HasProtectedParts = main.Document.Descendants<DocumentProtection>().Any(),
Images = main.ImageParts.Count(), Headers = headers, Footers = footers, StyleDetails = GetStyles(main), SectionAssignments = GetSectionAssignments(main), InsertionPoint = FindInsertionPoint(main.Document), Warnings = FindWarnings(roots)
HasPageFields = text.Contains("PAGE", StringComparison.OrdinalIgnoreCase), HasNumPagesFields = text.Contains("NUMPAGES", StringComparison.OrdinalIgnoreCase),
HasSectionPagesFields = text.Contains("SECTIONPAGES", StringComparison.OrdinalIgnoreCase), Bookmarks = main.Document.Descendants<BookmarkStart>().Count(),
ContentControls = main.Document.Descendants<SdtElement>().Count(), Comments = package.MainDocumentPart.GetPartsOfType<WordprocessingCommentsPart>().Count(),
HasTrackedChanges = main.Document.Descendants<Inserted>().Any() || main.Document.Descendants<Deleted>().Any(),
HasProtectedParts = main.Document.Descendants<DocumentProtection>().Any(),
Styles = main.StyleDefinitionsPart?.Styles?.Elements<Style>().Select(s => s.StyleId?.Value ?? "").Where(s => s.Length > 0).Distinct().Order().ToArray() ?? [],
Warnings = warnings
}; };
} }
private static IReadOnlyList<(OpenXmlPartRootElement Root, string Part, int? Section)> GetRoots(MainDocumentPart main)
{
var roots = new List<(OpenXmlPartRootElement, string, int?)> { (main.Document, "MainDocumentPart", null) }; roots.AddRange(main.HeaderParts.Select((p, i) => ((OpenXmlPartRootElement)p.Header, $"HeaderPart[{i + 1}]", (int?)null))); roots.AddRange(main.FooterParts.Select((p, i) => ((OpenXmlPartRootElement)p.Footer, $"FooterPart[{i + 1}]", (int?)null))); if (main.DocumentSettingsPart?.Settings is not null) roots.Add((main.DocumentSettingsPart.Settings, "SettingsPart", null)); if (main.FootnotesPart?.Footnotes is not null) roots.Add((main.FootnotesPart.Footnotes, "FootnotesPart", null)); if (main.EndnotesPart?.Endnotes is not null) roots.Add((main.EndnotesPart.Endnotes, "EndnotesPart", null)); foreach (var comments in main.GetPartsOfType<WordprocessingCommentsPart>()) if (comments.Comments is not null) roots.Add((comments.Comments, "CommentsPart", null)); return roots;
}
private static IEnumerable<DocumentFieldInfo> FindFields(OpenXmlElement root, string part, int? section)
{
foreach (var simple in root.Descendants<SimpleField>()) { var type = NormalizeField(simple.Instruction?.Value); if (type is not null) yield return new DocumentFieldInfo { Type = type, Part = part, Section = section, Instruction = simple.Instruction?.Value }; }
var active = new List<string>(); foreach (var element in root.Descendants()) { if (element is FieldChar begin && begin.FieldCharType?.Value == FieldCharValues.Begin) active.Add(""); else if (element is FieldCode code && active.Count > 0) active[^1] += " " + code.Text; else if (element is FieldChar end && end.FieldCharType?.Value == FieldCharValues.End && active.Count > 0) { var raw = active[^1]; active.RemoveAt(active.Count - 1); var type = NormalizeField(raw); if (type is not null) yield return new DocumentFieldInfo { Type = type, Part = part, Section = section, Instruction = raw.Trim() }; } }
}
private static string? NormalizeField(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var normalized = Regex.Replace(value, @"\s+", " ").Trim(); var name = Regex.Match(normalized, @"(?i)^(PAGE|NUMPAGES|SECTIONPAGES|FILENAME)\b").Value.ToUpperInvariant(); return KnownFields.Contains(name) ? name : null; }
private static DocumentImageSummary CountImages(MainDocumentPart main, IReadOnlyList<(OpenXmlPartRootElement Root, string Part, int? Section)> roots)
{
var imageParts = new HashSet<OpenXmlPart>(); var drawings = roots.Sum(x => x.Root.Descendants<Drawing>().Count()); var shapes = roots.Sum(x => x.Root.Descendants<DocumentFormat.OpenXml.Vml.Shape>().Count()); var external = 0;
foreach (var image in main.GetPartsOfType<ImagePart>()) imageParts.Add(image); foreach (var header in main.HeaderParts) { foreach (var image in header.GetPartsOfType<ImagePart>()) imageParts.Add(image); external += header.ExternalRelationships.Count(x => x.Uri?.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) == true); } foreach (var footer in main.FooterParts) { foreach (var image in footer.GetPartsOfType<ImagePart>()) imageParts.Add(image); external += footer.ExternalRelationships.Count(x => x.Uri?.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) == true); }
external += main.ExternalRelationships.Count(x => x.Uri?.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) == true); return new DocumentImageSummary { RasterImages = imageParts.Count, DrawingObjects = drawings, VmlShapes = shapes, ExternalImages = external };
}
private static IReadOnlyList<DocumentStyleInfo> GetStyles(MainDocumentPart main)
{
var usage = main.Document.Descendants<ParagraphStyleId>().GroupBy(x => x.Val?.Value ?? "").ToDictionary(x => x.Key, x => x.Count()); return main.StyleDefinitionsPart?.Styles?.Elements<Style>().Select(style => new DocumentStyleInfo { StyleId = style.StyleId?.Value ?? "", Name = style.StyleName?.Val?.Value, Type = style.Type is null ? null : style.Type.Value.ToString(), IsDefault = style.Default?.Value == true, BasedOn = style.BasedOn?.Val?.Value, UsageCount = usage.GetValueOrDefault(style.StyleId?.Value ?? "") }).Where(x => x.StyleId.Length > 0).ToArray() ?? [];
}
private static IReadOnlyList<SectionPartAssignment> GetSectionAssignments(MainDocumentPart main)
{
var result = new List<SectionPartAssignment>(); var sections = main.Document.Body?.Elements<SectionProperties>().ToArray() ?? []; for (var i = 0; i < sections.Length; i++) { var section = sections[i]; var headers = section.Elements<HeaderReference>().Select(x => $"{x.Type?.Value}:{PartName(main, x.Id?.Value)}").ToArray(); var footers = section.Elements<FooterReference>().Select(x => $"{x.Type?.Value}:{PartName(main, x.Id?.Value)}").ToArray(); result.Add(new SectionPartAssignment { Section = i + 1, Headers = headers, Footers = footers, LinkToPrevious = i > 0 && headers.Length == 0 && footers.Length == 0 }); } return result;
}
private static string PartName(MainDocumentPart main, string? id) { if (id is null) return "missing"; try { return main.GetPartById(id).Uri.ToString(); } catch { return "unresolved:" + id; } }
private static InsertionPointInfo FindInsertionPoint(Document root) { if (root.Descendants<SdtElement>().Any(x => string.Equals(x.SdtProperties?.GetFirstChild<Tag>()?.Val?.Value, "DOC_BODY", StringComparison.OrdinalIgnoreCase))) return new InsertionPointInfo { Type = "ContentControl", Location = "MainDocumentPart" }; if (root.Descendants<BookmarkStart>().Any(x => string.Equals(x.Name?.Value, "DOC_BODY", StringComparison.OrdinalIgnoreCase))) return new InsertionPointInfo { Type = "Bookmark", Location = "MainDocumentPart" }; if (root.InnerText.Contains("{{DOC_BODY}}", StringComparison.Ordinal)) return new InsertionPointInfo { Type = "TextMarker", Location = "MainDocumentPart" }; return new InsertionPointInfo { Type = "None" }; }
private static IReadOnlyList<InspectionWarning> FindWarnings(IReadOnlyList<(OpenXmlPartRootElement Root, string Part, int? Section)> roots) { var result = new List<InspectionWarning>(); foreach (var root in roots) foreach (Match match in Regex.Matches(root.Root.InnerText, @"(?i)\b(2019|legacy|старый)\b")) result.Add(new InspectionWarning { Code = "PossibleLegacyProjectData", Message = "A value matched the legacy-project heuristic.", Part = root.Part, Location = "text", MatchedValue = match.Value, DetectionRule = "word-boundary: 2019|legacy|старый" }); return result; }
}
public sealed class TextDocumentInspector : IDocumentInspector
{
public DocumentInspectionResult Inspect(string path) { if (!File.Exists(path)) throw new FileNotFoundException("TXT was not found.", path); if (!path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException($"TXT inspector accepts TXT only, received {Path.GetExtension(path)}."); var info = new FileInfo(path); return new DocumentInspectionResult { FilePath = path, FileSize = info.Length, LastWriteTimeUtc = info.LastWriteTimeUtc, Sections = 1, Paragraphs = File.ReadAllLines(path).Length, InsertionPoint = new InsertionPointInfo { Type = "None" } }; }
}
public sealed class DocumentInspector : IDocumentInspector
{
private readonly OpenXmlDocumentInspector _inner = new();
public DocumentInspectionResult Inspect(string docxPath) => _inner.Inspect(docxPath);
} }
@@ -9,7 +9,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" /> <ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
<ProjectReference Include="..\DocxWorkbench.Core\DocxWorkbench.Core.csproj" /> <ProjectReference Include="..\DocxWorkbench.Core\DocxWorkbench.Core.csproj" />
<PackageReference Include="Microsoft.Office.Interop.Word" Version="15.0.4797.1004" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,40 @@
using System.Collections.Concurrent;
namespace DocxWorkbench.WordInterop;
internal sealed class StaWordQueue : IDisposable
{
private readonly BlockingCollection<Action> _jobs = new();
private readonly Thread _thread;
public StaWordQueue()
{
_thread = new Thread(Run) { IsBackground = true, Name = "DocumentAutomation.Word.STA" };
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
}
public Task<T> Enqueue<T>(Func<T> operation, CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
_jobs.Add(() =>
{
try { cancellationToken.ThrowIfCancellationRequested(); completion.SetResult(operation()); }
catch (OperationCanceledException ex) { completion.SetCanceled(ex.CancellationToken); }
catch (Exception ex) { completion.SetException(ex); }
}, cancellationToken);
return completion.Task;
}
private void Run()
{
foreach (var job in _jobs.GetConsumingEnumerable()) job();
}
public void Dispose()
{
_jobs.CompleteAdding();
if (Thread.CurrentThread != _thread) _thread.Join(TimeSpan.FromSeconds(5));
_jobs.Dispose();
}
}
@@ -1,55 +1,97 @@
using System.Diagnostics; using System.Diagnostics;
using System.IO.Compression;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Reflection;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using DocumentAutomation.Shared; using DocumentAutomation.Shared;
using DocxWorkbench.Core; using DocxWorkbench.Core;
using Word = Microsoft.Office.Interop.Word;
namespace DocxWorkbench.WordInterop; namespace DocxWorkbench.WordInterop;
public sealed class WordDocumentService : IWordDocumentService 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();
public Task<WordDocumentResult> BuildDocumentAsync(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken cancellationToken) public Task<WordDocumentResult> BuildDocumentAsync(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken cancellationToken)
=> Task.Run(() => Build(request, progress, cancellationToken), cancellationToken); => _queue.Enqueue(() => BuildOnSta(request, progress, cancellationToken), cancellationToken);
public Task<WordPreviewResult> ExportPdfAsync(string docxPath, string outputPdfPath, CancellationToken cancellationToken) public Task<WordPreviewResult> ExportPdfAsync(string docxPath, string outputPdfPath, CancellationToken cancellationToken)
=> Task.Run(() => ExportPdf(docxPath, outputPdfPath, cancellationToken), cancellationToken); => _queue.Enqueue(() => ExportPdfOnSta(docxPath, outputPdfPath, cancellationToken), cancellationToken);
private static WordDocumentResult Build(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken token) private static WordDocumentResult BuildOnSta(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken token)
{ {
DocumentInputValidation.Validate(request); var started = Stopwatch.StartNew(); DocumentInputValidation.Validate(request);
var temp = Path.Combine(Path.GetTempPath(), "DocumentAutomation", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temp); 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 working = Path.Combine(temp, "working.docx"); File.Copy(request.TemplatePath, working, true); var insertion = TemplateInsertionPointReader.Find(request.TemplatePath);
Word.Application? app = null; Word.Document? document = null; string insertion = "none"; int? pid = null; 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;
try try
{ {
token.ThrowIfCancellationRequested(); progress?.Report("Запуск Word COM"); app = new Word.Application { Visible = false, DisplayAlerts = Word.WdAlertLevel.wdAlertsNone }; pid = GetProcessId(app); 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"})");
document = app.Documents.Open(working, ReadOnly: false, AddToRecentFiles: false, Visible: false); 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.");
progress?.Report("Поиск точки вставки DOC_BODY"); var range = FindInsertionRange(document, out insertion); range = Own(FindInsertionRange(document, insertion.Type, proxies), proxies) ?? throw new WordAutomationException("The insertion point disappeared while opening the template.");
if (range is null) throw new InvalidOperationException("В шаблоне не найдена точка вставки DOC_BODY."); 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(); range.InsertFile(request.BodyPath, ConfirmConversions: false, Link: false, Attachment: false); token.ThrowIfCancellationRequested(); progress?.Report("Updating fields and pagination"); document.Repaginate(); document.Fields.Update(); UpdateHeadersFooters(document, proxies); document.SaveAs2(stagedOutput, 12); CommitOutput(stagedOutput, request.OutputDocxPath);
progress?.Report("Обновление полей и пагинация"); document.Repaginate(); document.Fields.Update(); 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 }));
for (var i = 1; i <= document.Sections.Count; i++) { var section = document.Sections[i]; foreach (Word.HeaderFooter hf in section.Headers) hf.Range.Fields.Update(); foreach (Word.HeaderFooter hf in section.Footers) hf.Range.Fields.Update(); } return new WordDocumentResult { OutputDocxPath = request.OutputDocxPath, LogPath = logPath, Duration = started.Elapsed, InsertionPoint = insertion.Type };
document.SaveAs2(request.OutputDocxPath, Word.WdSaveFormat.wdFormatXMLDocument);
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, wordPid = pid, durationMs = started.ElapsedMilliseconds, completedAtUtc = DateTime.UtcNow }, new JsonSerializerOptions { WriteIndented = true }));
return new WordDocumentResult { OutputDocxPath = request.OutputDocxPath, LogPath = logPath, Duration = started.Elapsed, InsertionPoint = insertion };
} }
finally { if (document is not null) { try { document.Close(Word.WdSaveOptions.wdDoNotSaveChanges); } catch { } Marshal.FinalReleaseComObject(document); } if (app is not null) { try { app.Quit(Word.WdSaveOptions.wdDoNotSaveChanges); } catch { } Marshal.FinalReleaseComObject(app); } try { Directory.Delete(temp, true); } catch { } } 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); }
private static WordPreviewResult ExportPdf(string docxPath, string outputPdfPath, CancellationToken token) finally
{ {
if (!File.Exists(docxPath)) throw new FileNotFoundException("DOCX не найден.", docxPath); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPdfPath))!); var sw = Stopwatch.StartNew(); Word.Application? app = null; Word.Document? doc = null; try { if (document is not null) document.Close(0); } catch { }
try { token.ThrowIfCancellationRequested(); app = new Word.Application { Visible = false, DisplayAlerts = Word.WdAlertLevel.wdAlertsNone }; doc = app.Documents.Open(docxPath, ReadOnly: true, AddToRecentFiles: false, Visible: false); doc.ExportAsFixedFormat(outputPdfPath, Word.WdExportFormat.wdExportFormatPDF); return new WordPreviewResult { PdfPath = outputPdfPath, Duration = sw.Elapsed }; } try { if (application is not null) application.Quit(0); } catch { }
finally { if (doc is not null) { try { doc.Close(Word.WdSaveOptions.wdDoNotSaveChanges); } catch { } Marshal.FinalReleaseComObject(doc); } if (app is not null) { try { app.Quit(Word.WdSaveOptions.wdDoNotSaveChanges); } catch { } Marshal.FinalReleaseComObject(app); } } ReleaseAll(proxies); TryDeleteDirectory(temp);
}
} }
private static Word.Range? FindInsertionRange(Word.Document document, out string point) private static WordPreviewResult ExportPdfOnSta(string docxPath, string outputPdfPath, CancellationToken token)
{ {
foreach (Word.ContentControl control in document.ContentControls) if (string.Equals(control.Tag, "DOC_BODY", StringComparison.OrdinalIgnoreCase) || string.Equals(control.Title, "DOC_BODY", StringComparison.OrdinalIgnoreCase)) { point = "ContentControl:DOC_BODY"; return control.Range; } 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<object?>();
if (document.Bookmarks.Exists("DOC_BODY")) { point = "Bookmark:DOC_BODY"; return document.Bookmarks["DOC_BODY"].Range; } 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 }; }
var search = document.Content; search.Find.ClearFormatting(); search.Find.Text = "{{DOC_BODY}}"; if (search.Find.Execute()) { point = "Marker:{{DOC_BODY}}"; search.Text = ""; return search; } point = "none"; return null; 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 int? GetProcessId(Word.Application app) { try { return Process.GetProcessesByName("WINWORD").OrderByDescending(p => p.StartTime).FirstOrDefault()?.Id; } catch { return null; } } private static dynamic? FindInsertionRange(dynamic document, string type, List<object?> 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<object?> 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<object?> 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 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 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" };
}
} }
@@ -9,7 +9,7 @@
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
</PropertyGroup> </PropertyGroup>
<ItemGroup><ProjectReference Include="..\..\src\DocxWorkbench.WordInterop\DocxWorkbench.WordInterop.csproj" /><ProjectReference Include="..\..\src\StandardsWorkbench.Pdf\StandardsWorkbench.Pdf.csproj" /></ItemGroup> <ItemGroup><ProjectReference Include="..\..\src\DocxWorkbench.WordInterop\DocxWorkbench.WordInterop.csproj" /><ProjectReference Include="..\..\src\StandardsWorkbench.Pdf\StandardsWorkbench.Pdf.csproj" /><PackageReference Include="DocumentFormat.OpenXml" Version="3.3.0" /></ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" /> <PackageReference Include="coverlet.collector" Version="6.0.0" />
@@ -1,16 +1,45 @@
using System.Diagnostics;
using System.Text.Json;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using DocumentAutomation.Shared;
using DocxWorkbench.WordInterop;
using Xunit.Sdk;
namespace DocumentAutomation.IntegrationTests; namespace DocumentAutomation.IntegrationTests;
public sealed class WordComIntegrationTests public sealed class WordComIntegrationTests
{ {
[Fact] [Fact]
public void IntegrationAssemblyIsAvailable() public void IntegrationAssemblyIsAvailable() => Assert.True(typeof(WordComIntegrationTests).Assembly.GetName().Name is not null);
[Fact]
public void WordAvailability_DoesNotRequireOfficePia()
{ {
Assert.True(typeof(WordComIntegrationTests).Assembly.GetName().Name is not null); var names = typeof(WordDocumentService).Assembly.GetReferencedAssemblies().Select(x => x.Name).ToArray(); Assert.DoesNotContain(names, name => string.Equals(name, "office", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "Microsoft.Office.Interop.Word", StringComparison.OrdinalIgnoreCase));
} }
[Fact(Skip = "Requires an interactive Microsoft Word installation; not run automatically in the repository validation pass.")] [Fact]
public void BuildsDocxAndExportsPdfThroughWordCom() public async Task Build_WithoutInsertionPoint_DoesNotStartWord()
{ {
Assert.Fail("Run this test manually on a Windows workstation with Word installed and a synthetic template/body fixture."); var template = CreateDocx(false); var body = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); File.WriteAllText(body, "text"); var before = ProcessIds(); try { using var service = new WordDocumentService(); await Assert.ThrowsAsync<WordAutomationException>(() => service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx") }, null, CancellationToken.None)); Assert.Empty(ProcessIds().Except(before)); } finally { File.Delete(template); File.Delete(body); }
} }
[Fact] public async Task Build_RunsOnStaThread() { await RequireWord(); var result = await BuildTxt(); var log = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText(result.LogPath)); Assert.Equal("STA", log.GetProperty("staApartment").GetString()); }
[Fact] public async Task Build_WithTxtBody_InsertsText() { await RequireWord(); var result = await BuildTxt(); Assert.Contains("TXT integration body", ReadMainText(result.OutputDocxPath)); }
[Fact] public async Task Build_WithDocxBody_InsertsDocument() { await RequireWord(); var template = CreateDocx(true); var body = CreateDocx(false, "DOCX integration body"); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); try { using var service = new WordDocumentService(); await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = output }, null, CancellationToken.None); Assert.Contains("DOCX integration body", ReadMainText(output)); } finally { File.Delete(template); File.Delete(body); File.Delete(output); } }
[Fact] public async Task Build_ExportsPdf() { await RequireWord(); var result = await BuildTxt(); using var service = new WordDocumentService(); var pdf = await service.ExportPdfAsync(result.OutputDocxPath, Path.ChangeExtension(result.OutputDocxPath, ".pdf"), CancellationToken.None); Assert.True(File.Exists(pdf.PdfPath)); File.Delete(result.OutputDocxPath); File.Delete(pdf.PdfPath); File.Delete(result.LogPath); }
[Fact] public async Task Build_DoesNotLeaveCreatedWinwordProcess() { await RequireWord(); var before = ProcessIds(); var result = await BuildTxt(); await Task.Delay(500); Assert.Empty(ProcessIds().Except(before)); File.Delete(result.OutputDocxPath); File.Delete(result.LogPath); }
[Fact] public async Task Build_Failure_ReleasesWord() { await RequireWord(); var before = ProcessIds(); var template = CreateDocx(true); var body = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); var outputDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); Directory.CreateDirectory(outputDirectory); File.WriteAllText(body, "failure body"); try { using var service = new WordDocumentService(); await Assert.ThrowsAnyAsync<Exception>(() => service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = outputDirectory }, null, CancellationToken.None)); await Task.Delay(500); Assert.Empty(ProcessIds().Except(before)); } finally { File.Delete(template); File.Delete(body); Directory.Delete(outputDirectory, true); } }
[Fact]
public async Task RuntimeGate_DesktopDocxBodyUsesTemplateCopy()
{
await RequireWord(); var template = @"C:\Users\codex\Desktop\156-титул-ПД.docx"; var body = @"C:\Users\codex\Desktop\НХС-33-УК3-ОЗХ-13011-000-НК-ОД.docx"; if (!File.Exists(template) || !File.Exists(body)) throw SkipException.ForSkip("The supplied Desktop DOCX fixtures are not available."); var copy = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); File.Copy(template, copy); try { using (var document = WordprocessingDocument.Open(copy, true)) { var main = document.MainDocumentPart ?? throw new InvalidDataException("Main part missing."); main.Document.Body!.Append(new Paragraph(new Run(new Text("{{DOC_BODY}}")))); main.Document.Save(); } using var service = new WordDocumentService(); var result = await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = copy, BodyPath = body, OutputDocxPath = output }, null, CancellationToken.None); Assert.True(File.Exists(result.OutputDocxPath)); } finally { File.Delete(copy); File.Delete(output); File.Delete(Path.ChangeExtension(output, ".generation.json")); }
}
private static async Task RequireWord() { if (!OperatingSystem.IsWindows() || Type.GetTypeFromProgID("Word.Application", false) is null) throw SkipException.ForSkip("Microsoft Word COM is not installed or registered on this workstation."); await Task.CompletedTask; }
private static async Task<WordDocumentResult> BuildTxt() { var template = CreateDocx(true); var body = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); File.WriteAllText(body, "TXT integration body"); try { using var service = new WordDocumentService(); return await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = output }, null, CancellationToken.None); } finally { File.Delete(template); File.Delete(body); } }
private static HashSet<int> ProcessIds() => Process.GetProcessesByName("WINWORD").Select(x => x.Id).ToHashSet();
private static string ReadMainText(string path) { using var doc = WordprocessingDocument.Open(path, false); return doc.MainDocumentPart?.Document.InnerText ?? ""; }
private static string CreateDocx(bool marker, string text = "template body") { var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); using var doc = WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); var main = doc.AddMainDocumentPart(); main.Document = new Document(new Body(new Paragraph(new Run(new Text(marker ? "{{DOC_BODY}}" : text))))); main.Document.Save(); return path; }
} }
@@ -1,5 +1,6 @@
using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing; using DocumentFormat.OpenXml.Wordprocessing;
using DocxWorkbench.Core;
using DocxWorkbench.OpenXml; using DocxWorkbench.OpenXml;
namespace DocxWorkbench.Tests; namespace DocxWorkbench.Tests;
@@ -7,14 +8,47 @@ namespace DocxWorkbench.Tests;
public sealed class DocumentInspectorTests public sealed class DocumentInspectorTests
{ {
[Fact] [Fact]
public void InspectsSyntheticDocxStructure() public void Analyze_DocxTemplateAndTxtBody_Succeeds()
{ {
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); var docx = CreateDocx(marker: true); var txt = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); File.WriteAllText(txt, "line one\nline two"); try { var template = new OpenXmlDocumentInspector().Inspect(docx); var body = new TextDocumentInspector().Inspect(txt); Assert.Equal("TextMarker", template.InsertionPoint.Type); Assert.Equal(2, body.Paragraphs); } finally { File.Delete(docx); File.Delete(txt); }
using (var doc = WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document))
{
var main = doc.AddMainDocumentPart(); main.Document = new Document(new Body(new Paragraph(new Run(new Text("{{DOC_BODY}}"))), new Table(new TableRow(new TableCell(new Paragraph(new Run(new Text("x")))))))); main.Document.Save();
} }
try { var result = new DocumentInspector().Inspect(path); Assert.Equal(1, result.Tables); Assert.True(result.Paragraphs >= 2); Assert.True(result.HasPageFields is false); }
finally { File.Delete(path); } [Fact]
public void Analyze_DocxTemplateAndDocxBody_Succeeds()
{
var template = CreateDocx(marker: true); var body = CreateDocx(marker: false); try { var inspector = new OpenXmlDocumentInspector(); Assert.Equal("TextMarker", inspector.Inspect(template).InsertionPoint.Type); Assert.Equal(1, inspector.Inspect(body).Sections); } finally { File.Delete(template); File.Delete(body); }
}
[Fact]
public void Inspect_DetectsMarkerInsertionPoint()
{
var path = CreateDocx(marker: true); try { Assert.Equal("TextMarker", new OpenXmlDocumentInspector().Inspect(path).InsertionPoint.Type); } finally { File.Delete(path); }
}
[Fact]
public void Inspect_FindsPageFieldsInFooter()
{
var path = CreateDocx(marker: true, fields: true); try { var result = new OpenXmlDocumentInspector().Inspect(path); Assert.Contains(result.Fields, field => field.Type == "PAGE" && field.Part.StartsWith("FooterPart", StringComparison.Ordinal)); Assert.Contains(result.Fields, field => field.Type == "NUMPAGES"); } finally { File.Delete(path); }
}
[Fact]
public void Inspect_CountsHeaderFooterGraphics()
{
var path = CreateDocx(marker: true, graphics: true); try { var result = new OpenXmlDocumentInspector().Inspect(path); Assert.True(result.ImageSummary.RasterImages >= 2); } finally { File.Delete(path); }
}
[Fact]
public void Inspect_UnsupportedFormatIsDomainError()
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".bin"); File.WriteAllText(path, "not a document"); try { var exception = Assert.Throws<UnsupportedDocumentFormatException>(() => new OpenXmlDocumentInspector().Inspect(path)); Assert.Contains("Open XML inspector", exception.Message); } finally { File.Delete(path); }
}
private static string CreateDocx(bool marker, bool fields = false, bool graphics = false)
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); using var document = WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); var main = document.AddMainDocumentPart(); var body = new Body(new Paragraph(new Run(new Text(marker ? "{{DOC_BODY}}" : "body")))); main.Document = new Document(body);
var header = main.AddNewPart<HeaderPart>(); header.Header = new Header(new Paragraph(new Run(new Text("header")))); var footer = main.AddNewPart<FooterPart>();
if (fields) { var paragraph = new Paragraph(); paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin })); paragraph.Append(new Run(new FieldCode(" PAGE "))); paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End })); paragraph.Append(new SimpleField { Instruction = " NUMPAGES " }); footer.Footer = new Footer(paragraph); } else footer.Footer = new Footer(new Paragraph(new Run(new Text("footer"))));
if (graphics) { var image = header.AddImagePart(ImagePartType.Png); image.FeedData(new MemoryStream([137, 80, 78, 71])); var image2 = footer.AddImagePart(ImagePartType.Png); image2.FeedData(new MemoryStream([137, 80, 78, 71])); }
body.Append(new SectionProperties(new HeaderReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(header) }, new FooterReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(footer) })); main.Document.Save(); return path;
} }
} }
@@ -0,0 +1,14 @@
using DocxWorkbench.Core;
namespace DocxWorkbench.Tests;
public sealed class WorkbenchStateTests
{
[Fact]
public void VerifyButton_DisabledBeforeBuild() { var state = new WorkbenchStateController(); Assert.False(state.CanValidate); }
[Fact]
public void VerifyButton_DisabledAfterBuildFailure() { var state = Ready(); state.BeginBuild(); state.BuildFailed(); Assert.False(state.CanValidate); Assert.False(state.CanOpenDocx); }
[Fact]
public void VerifyButton_EnabledAfterBuildSuccess() { var state = Ready(); state.BeginBuild(); state.BuildSucceeded(); Assert.True(state.CanValidate); Assert.True(state.CanOpenDocx); Assert.True(state.CanOpenPdf); }
private static WorkbenchStateController Ready() { var state = new WorkbenchStateController(); state.InputsSelected(true); state.AnalysisCompleted(true, true, true); return state; }
}