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 OpenXmlDocumentInspector : IDocumentInspector { private static readonly string[] KnownFields = ["PAGE", "NUMPAGES", "SECTIONPAGES", "FILENAME"]; public DocumentInspectionResult Inspect(string docxPath) { 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 = 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().Count(), ContentControls = main.Document.Descendants().Count(), Comments = main.GetPartsOfType().Count(), HasTrackedChanges = main.Document.Descendants().Any() || main.Document.Descendants().Any(), HasProtectedParts = main.Document.Descendants().Any(), StyleDetails = GetStyles(main), SectionAssignments = GetSectionAssignments(main), InsertionPoint = FindInsertionPoint(main.Document), BodyImportPlan = CreateBodyImportPlan(main), 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()) if (comments.Comments is not null) roots.Add((comments.Comments, "CommentsPart", null)); return roots; } private static IEnumerable FindFields(OpenXmlElement root, string part, int? section) { foreach (var simple in root.Descendants()) { 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(); 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(); var drawings = roots.Sum(x => x.Root.Descendants().Count()); var shapes = roots.Sum(x => x.Root.Descendants().Count()); var external = 0; foreach (var image in main.GetPartsOfType()) imageParts.Add(image); foreach (var header in main.HeaderParts) { foreach (var image in header.GetPartsOfType()) 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()) 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 GetStyles(MainDocumentPart main) { var usage = main.Document.Descendants().GroupBy(x => x.Val?.Value ?? "").ToDictionary(x => x.Key, x => x.Count()); return main.StyleDefinitionsPart?.Styles?.Elements