diff --git a/DocumentAutomation/docs/docx-template-contract.md b/DocumentAutomation/docs/docx-template-contract.md index 315f658..3c126f7 100644 --- a/DocumentAutomation/docs/docx-template-contract.md +++ b/DocumentAutomation/docs/docx-template-contract.md @@ -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. diff --git a/DocumentAutomation/docs/word-com-lifecycle.md b/DocumentAutomation/docs/word-com-lifecycle.md index be1bfbf..7a04a2f 100644 --- a/DocumentAutomation/docs/word-com-lifecycle.md +++ b/DocumentAutomation/docs/word-com-lifecycle.md @@ -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. diff --git a/DocumentAutomation/src/DocumentAutomation.Shared/Models.cs b/DocumentAutomation/src/DocumentAutomation.Shared/Models.cs index 4c4e12c..2c9b9f1 100644 --- a/DocumentAutomation/src/DocumentAutomation.Shared/Models.cs +++ b/DocumentAutomation/src/DocumentAutomation.Shared/Models.cs @@ -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 Headers { get; init; } = []; + public IReadOnlyList 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 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 Styles { get; init; } = []; - public IReadOnlyList Warnings { get; init; } = []; + public IReadOnlyList StyleDetails { get; init; } = []; + public IReadOnlyList SectionAssignments { get; init; } = []; + public InsertionPointInfo InsertionPoint { get; init; } = new() { Type = "None" }; + public IReadOnlyList Warnings { get; init; } = []; + public IReadOnlyList Styles => StyleDetails.Select(x => x.StyleId).ToArray(); } public sealed record WordDocumentBuildRequest diff --git a/DocumentAutomation/src/DocxWorkbench.App/MainForm.cs b/DocumentAutomation/src/DocxWorkbench.App/MainForm.cs index 95f69b1..1a4d9ae 100644 --- a/DocumentAutomation/src/DocxWorkbench.App/MainForm.cs +++ b/DocumentAutomation/src/DocxWorkbench.App/MainForm.cs @@ -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(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> 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(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 }); } } diff --git a/DocumentAutomation/src/DocxWorkbench.Core/Contracts.cs b/DocumentAutomation/src/DocxWorkbench.Core/Contracts.cs index 9265e87..1b2442b 100644 --- a/DocumentAutomation/src/DocxWorkbench.Core/Contracts.cs +++ b/DocumentAutomation/src/DocxWorkbench.Core/Contracts.cs @@ -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 BuildDocumentAsync(WordDocumentBuildRequest request, IProgress? progress, CancellationToken cancellationToken); diff --git a/DocumentAutomation/src/DocxWorkbench.OpenXml/DocumentInspector.cs b/DocumentAutomation/src/DocxWorkbench.OpenXml/DocumentInspector.cs index eb45bca..4c6613a 100644 --- a/DocumentAutomation/src/DocxWorkbench.OpenXml/DocumentInspector.cs +++ b/DocumentAutomation/src/DocxWorkbench.OpenXml/DocumentInspector.cs @@ -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(); - if (Regex.IsMatch(text, "2019|старый|legacy", RegexOptions.IgnoreCase)) warnings.Add("Обнаружены возможные старые проектные реквизиты; проверьте вручную."); - if (main.Document.Descendants().Any(s => s.GetFirstChild() 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().Count()), - Paragraphs = main.Document.Descendants().Count(), Tables = main.Document.Descendants().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().Count(), - ContentControls = main.Document.Descendants().Count(), Comments = package.MainDocumentPart.GetPartsOfType().Count(), - HasTrackedChanges = main.Document.Descendants().Any() || main.Document.Descendants().Any(), - HasProtectedParts = main.Document.Descendants().Any(), - Styles = main.StyleDefinitionsPart?.Styles?.Elements