fix: restore DOCX workbench runtime path
This commit is contained in:
@@ -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 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 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;
|
||||
|
||||
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 required string FilePath { get; init; }
|
||||
@@ -11,18 +62,23 @@ public sealed record DocumentInspectionResult
|
||||
public int Paragraphs { get; init; }
|
||||
public int Tables { get; init; }
|
||||
public int Images { get; init; }
|
||||
public DocumentImageSummary ImageSummary { get; init; } = new();
|
||||
public int Headers { get; init; }
|
||||
public int Footers { get; init; }
|
||||
public bool HasPageFields { get; init; }
|
||||
public bool HasNumPagesFields { get; init; }
|
||||
public bool HasSectionPagesFields { get; init; }
|
||||
public IReadOnlyList<DocumentFieldInfo> Fields { get; init; } = [];
|
||||
public int Bookmarks { get; init; }
|
||||
public int ContentControls { get; init; }
|
||||
public int Comments { get; init; }
|
||||
public bool HasTrackedChanges { get; init; }
|
||||
public bool HasProtectedParts { get; init; }
|
||||
public IReadOnlyList<string> Styles { get; init; } = [];
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
public IReadOnlyList<DocumentStyleInfo> StyleDetails { 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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocxWorkbench.Core;
|
||||
@@ -10,24 +9,47 @@ namespace DocxWorkbench.App;
|
||||
|
||||
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()
|
||||
{
|
||||
Text = "DOCX Workbench"; Width = 980; Height = 680; 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, "Шаблон DOCX", _template, BrowseTemplate); AddRow(grid, 1, "Тело документа", _body, BrowseBody); AddRow(grid, 2, "Выходной DOCX", _output, BrowseOutput);
|
||||
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);
|
||||
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;
|
||||
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 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);
|
||||
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;
|
||||
foreach (var box in new[] { _template, _body, _output }) box.TextChanged += (_, _) => InputsChanged(); RefreshButtons();
|
||||
}
|
||||
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 void BrowseTemplate(object? s, EventArgs e) { using var d = new OpenFileDialog { Filter = "DOCX|*.docx" }; if (d.ShowDialog() == DialogResult.OK) _template.Text = d.FileName; }
|
||||
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 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 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 async Task InspectOutputAsync() => await RunAsync("Проверка результата", () => Task.Run(() => (object)_inspector.Inspect(_output.Text)));
|
||||
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 static void Open(string path) { if (File.Exists(path)) Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); }
|
||||
|
||||
private static void AddTab(TabControl tabs, string title, Control content) { var tab = new TabPage(title); tab.Controls.Add(content); tabs.TabPages.Add(tab); }
|
||||
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 static Button Button(string text, EventHandler handler) { var button = new Button { Text = text, AutoSize = true }; button.Click += handler; return button; }
|
||||
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 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 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 AnalyzeAsync()
|
||||
{
|
||||
_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);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
Task<WordDocumentResult> BuildDocumentAsync(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken cancellationToken);
|
||||
|
||||
@@ -1,39 +1,72 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using DocxWorkbench.Core;
|
||||
|
||||
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)
|
||||
{
|
||||
if (!File.Exists(docxPath)) throw new FileNotFoundException("DOCX не найден.", docxPath);
|
||||
var info = new FileInfo(docxPath);
|
||||
using var package = WordprocessingDocument.Open(docxPath, false);
|
||||
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.");
|
||||
if (!File.Exists(docxPath)) throw new FileNotFoundException("DOCX was not found.", docxPath);
|
||||
if (!docxPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException($"Open XML inspector accepts DOCX only, received {Path.GetExtension(docxPath)}.");
|
||||
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);
|
||||
return new DocumentInspectionResult
|
||||
{
|
||||
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 = main.ImageParts.Count(), Headers = headers, Footers = footers,
|
||||
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
|
||||
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,
|
||||
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,
|
||||
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(),
|
||||
StyleDetails = GetStyles(main), SectionAssignments = GetSectionAssignments(main), InsertionPoint = FindInsertionPoint(main.Document), Warnings = FindWarnings(roots)
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
|
||||
<ProjectReference Include="..\DocxWorkbench.Core\DocxWorkbench.Core.csproj" />
|
||||
<PackageReference Include="Microsoft.Office.Interop.Word" Version="15.0.4797.1004" />
|
||||
</ItemGroup>
|
||||
|
||||
</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.IO.Compression;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocxWorkbench.Core;
|
||||
using Word = Microsoft.Office.Interop.Word;
|
||||
|
||||
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)
|
||||
=> Task.Run(() => Build(request, progress, cancellationToken), cancellationToken);
|
||||
=> _queue.Enqueue(() => BuildOnSta(request, progress, 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();
|
||||
var temp = Path.Combine(Path.GetTempPath(), "DocumentAutomation", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temp);
|
||||
var working = Path.Combine(temp, "working.docx"); File.Copy(request.TemplatePath, working, true);
|
||||
Word.Application? app = null; Word.Document? document = null; string insertion = "none"; int? pid = null;
|
||||
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;
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested(); progress?.Report("Запуск Word COM"); app = new Word.Application { Visible = false, DisplayAlerts = Word.WdAlertLevel.wdAlertsNone }; pid = GetProcessId(app);
|
||||
document = app.Documents.Open(working, ReadOnly: false, AddToRecentFiles: false, Visible: false);
|
||||
progress?.Report("Поиск точки вставки DOC_BODY"); var range = FindInsertionRange(document, out insertion);
|
||||
if (range is null) throw new InvalidOperationException("В шаблоне не найдена точка вставки DOC_BODY.");
|
||||
token.ThrowIfCancellationRequested(); range.InsertFile(request.BodyPath, ConfirmConversions: false, Link: false, Attachment: false);
|
||||
progress?.Report("Обновление полей и пагинация"); document.Repaginate(); document.Fields.Update();
|
||||
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(); }
|
||||
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 };
|
||||
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);
|
||||
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 };
|
||||
}
|
||||
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 { } }
|
||||
}
|
||||
|
||||
private static WordPreviewResult ExportPdf(string docxPath, string outputPdfPath, CancellationToken token)
|
||||
catch (WordNotInstalledException) { throw; }
|
||||
catch (WordAutomationException) { throw; }
|
||||
catch (Exception ex) when (ex is not OperationCanceledException) { throw new WordAutomationException("Word automation failed. See technical details in the operation log.", ex); }
|
||||
finally
|
||||
{
|
||||
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 { 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 }; }
|
||||
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); } }
|
||||
try { if (document is not null) document.Close(0); } catch { }
|
||||
try { if (application is not null) application.Quit(0); } catch { }
|
||||
ReleaseAll(proxies); TryDeleteDirectory(temp);
|
||||
}
|
||||
}
|
||||
|
||||
private static 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 (document.Bookmarks.Exists("DOC_BODY")) { point = "Bookmark:DOC_BODY"; return document.Bookmarks["DOC_BODY"].Range; }
|
||||
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;
|
||||
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?>();
|
||||
try { token.ThrowIfCancellationRequested(); var wordType = Type.GetTypeFromProgID("Word.Application", false) ?? throw new WordNotInstalledException("Microsoft Word is not installed or is not registered in COM."); application = Own(Activator.CreateInstance(wordType), proxies) ?? throw new WordAutomationException("Could not create Word.Application."); application.Visible = false; application.DisplayAlerts = 0; TrySetAutomationSecurity(application); documents = Own(application.Documents, proxies); document = Own(documents.Open(docxPath, true, false, false), proxies) ?? throw new WordAutomationException("Word could not open the DOCX."); document.ExportAsFixedFormat(outputPdfPath, 17); return new WordPreviewResult { PdfPath = outputPdfPath, Duration = sw.Elapsed }; }
|
||||
catch (WordNotInstalledException) { throw; }
|
||||
catch (WordAutomationException) { throw; }
|
||||
catch (Exception ex) when (ex is not OperationCanceledException) { throw new WordAutomationException("PDF export through Word failed.", ex); }
|
||||
finally { try { if (document is not null) document.Close(0); } catch { } try { if (application is not null) application.Quit(0); } catch { } ReleaseAll(proxies); }
|
||||
}
|
||||
|
||||
private static 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" };
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</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>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
|
||||
+34
-5
@@ -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;
|
||||
|
||||
public sealed class WordComIntegrationTests
|
||||
{
|
||||
[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.")]
|
||||
public void BuildsDocxAndExportsPdfThroughWordCom()
|
||||
[Fact]
|
||||
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.Wordprocessing;
|
||||
using DocxWorkbench.Core;
|
||||
using DocxWorkbench.OpenXml;
|
||||
|
||||
namespace DocxWorkbench.Tests;
|
||||
@@ -7,14 +8,47 @@ namespace DocxWorkbench.Tests;
|
||||
public sealed class DocumentInspectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void InspectsSyntheticDocxStructure()
|
||||
public void Analyze_DocxTemplateAndTxtBody_Succeeds()
|
||||
{
|
||||
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("{{DOC_BODY}}"))), new Table(new TableRow(new TableCell(new Paragraph(new Run(new Text("x")))))))); main.Document.Save();
|
||||
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); }
|
||||
}
|
||||
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user