diff --git a/Clippit.Tests/Word/DocumentAssemblerTests.cs b/Clippit.Tests/Word/DocumentAssemblerTests.cs index 978902e8..7ec56e3c 100644 --- a/Clippit.Tests/Word/DocumentAssemblerTests.cs +++ b/Clippit.Tests/Word/DocumentAssemblerTests.cs @@ -1297,4 +1297,212 @@ private FileInfo GetOutputFile(string templateName, string dataName = null) "The element has unexpected child element 'http://schemas.openxmlformats.org/wordprocessingml/2006/main:rFonts'.", "The element has unexpected child element 'http://schemas.openxmlformats.org/wordprocessingml/2006/main:kern'.", ]; + + // ── Custom handler tests ────────────────────────────────────────────────────────────── + + private static byte[] BuildMinimalDocxWithDirective(string directiveXml) + { + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + var bodyXml = new XElement( + w + "body", + new XElement(w + "p", new XElement(w + "r", new XElement(w + "t", directiveXml))), + new XElement(w + "sectPr") + ); + using var ms = new MemoryStream(); + using ( + var wordDoc = WordprocessingDocument.Create(ms, DocumentFormat.OpenXml.WordprocessingDocumentType.Document) + ) + { + var mainPart = wordDoc.AddMainDocumentPart(); + mainPart.PutXDocument(new XDocument(new XElement(w + "document", bodyXml))); + } + return ms.ToArray(); + } + + /// DA500 — a registered custom handler is invoked and its return value replaces the directive. + [Test] + public async Task DA500_CustomHandler_RegisterAndInvoke() + { + const string elementName = "DA500_Greeting"; + try + { + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + + DocumentAssembler.RegisterCustomHandler( + elementName, + schemaXsd: null, + handler: (directive, data, part) => + { + var name = (string)data.Element("Name") ?? "World"; + return new XElement(w + "p", new XElement(w + "r", new XElement(w + "t", $"Hello, {name}!"))); + } + ); + + var directive = $"<#<{elementName}/>#>"; + var docxBytes = BuildMinimalDocxWithDirective(directive); + var wmlTemplate = new WmlDocument("test500.docx", docxBytes); + var xmlData = XElement.Parse("Alice"); + + var result = DocumentAssembler.AssembleDocument(wmlTemplate, xmlData, out var hasError); + + await Assert.That(hasError).IsFalse(); + + using var resultStream = new MemoryStream(result.DocumentByteArray); + using var resultDoc = WordprocessingDocument.Open(resultStream, false); + var text = resultDoc + .MainDocumentPart!.GetXDocument() + .Descendants(w + "t") + .Select(t => (string)t) + .StringConcatenate(); + await Assert.That(text).Contains("Hello, Alice!"); + } + finally + { + DocumentAssembler.UnregisterCustomHandler(elementName); + } + } + + /// DA501 — a custom handler that throws maps to a template error. + [Test] + public async Task DA501_CustomHandler_ExceptionSetsTemplateError() + { + const string elementName = "DA501_BrokenHandler"; + try + { + DocumentAssembler.RegisterCustomHandler( + elementName, + schemaXsd: null, + handler: (_, _, _) => throw new InvalidOperationException("Simulated handler failure") + ); + + var directive = $"<#<{elementName}/>#>"; + var docxBytes = BuildMinimalDocxWithDirective(directive); + var wmlTemplate = new WmlDocument("test501.docx", docxBytes); + var xmlData = XElement.Parse(""); + + var result = DocumentAssembler.AssembleDocument(wmlTemplate, xmlData, out var hasError); + + await Assert.That(hasError).IsTrue(); + + using var resultStream = new MemoryStream(result.DocumentByteArray); + using var resultDoc = WordprocessingDocument.Open(resultStream, false); + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + var allText = resultDoc + .MainDocumentPart!.GetXDocument() + .Descendants(w + "t") + .Select(t => (string)t) + .StringConcatenate(); + await Assert.That(allText).Contains("Simulated handler failure"); + } + finally + { + DocumentAssembler.UnregisterCustomHandler(elementName); + } + } + + /// DA502 — a handler returning null silently removes the element. + [Test] + public async Task DA502_CustomHandler_NullReturnRemovesElement() + { + const string elementName = "DA502_NullHandler"; + try + { + DocumentAssembler.RegisterCustomHandler(elementName, schemaXsd: null, handler: (_, _, _) => null); + + var directive = $"<#<{elementName}/>#>"; + var docxBytes = BuildMinimalDocxWithDirective(directive); + var wmlTemplate = new WmlDocument("test502.docx", docxBytes); + var xmlData = XElement.Parse(""); + + var result = DocumentAssembler.AssembleDocument(wmlTemplate, xmlData, out var hasError); + + await Assert.That(hasError).IsFalse(); + + using var resultStream = new MemoryStream(result.DocumentByteArray); + using var resultDoc = WordprocessingDocument.Open(resultStream, false); + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + var allText = resultDoc + .MainDocumentPart!.GetXDocument() + .Descendants(w + "t") + .Select(t => (string)t) + .StringConcatenate() + .Trim(); + await Assert.That(allText).IsEqualTo(string.Empty); + } + finally + { + DocumentAssembler.UnregisterCustomHandler(elementName); + } + } + + /// DA503 — after UnregisterCustomHandler the element is treated as invalid XML. + [Test] + public async Task DA503_CustomHandler_AfterUnregisterTreatedAsError() + { + const string elementName = "DA503_TempHandler"; + + DocumentAssembler.RegisterCustomHandler(elementName, schemaXsd: null, handler: (_, _, _) => null); + DocumentAssembler.UnregisterCustomHandler(elementName); + + var directive = $"<#<{elementName}/>#>"; + var docxBytes = BuildMinimalDocxWithDirective(directive); + var wmlTemplate = new WmlDocument("test503.docx", docxBytes); + var xmlData = XElement.Parse(""); + + var result = DocumentAssembler.AssembleDocument(wmlTemplate, xmlData, out var hasError); + + await Assert.That(hasError).IsTrue(); + } + + /// DA504 — a custom handler with a schema validates directive attributes. + [Test] + public async Task DA504_CustomHandler_SchemaValidationRejectsInvalidAttributes() + { + const string elementName = "DA504_SchemaHandler"; + const string schema = + @" + + + + + + "; + try + { + DocumentAssembler.RegisterCustomHandler( + elementName, + schemaXsd: schema, + handler: (directive, data, part) => + { + var value = (string)data.XPathSelectElement((string)directive.Attribute("Select")); + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + return new XElement(w + "p", new XElement(w + "r", new XElement(w + "t", value ?? ""))); + } + ); + + // Valid: has required Select attribute + var validDirective = $"<#<{elementName} Select=\"./Name\"/>#>"; + var docxBytes = BuildMinimalDocxWithDirective(validDirective); + var wmlTemplate = new WmlDocument("test504.docx", docxBytes); + var xmlData = XElement.Parse("Schema Test"); + + var result = DocumentAssembler.AssembleDocument(wmlTemplate, xmlData, out var hasError); + await Assert.That(hasError).IsFalse(); + + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + using var resultStream = new MemoryStream(result.DocumentByteArray); + using var resultDoc = WordprocessingDocument.Open(resultStream, false); + var text = resultDoc + .MainDocumentPart!.GetXDocument() + .Descendants(w + "t") + .Select(t => (string)t) + .StringConcatenate(); + await Assert.That(text).Contains("Schema Test"); + } + finally + { + DocumentAssembler.UnregisterCustomHandler(elementName); + } + } } diff --git a/Clippit/Word/DocumentAssembler.cs b/Clippit/Word/DocumentAssembler.cs index 8bb16e8e..8ec1aac3 100644 --- a/Clippit/Word/DocumentAssembler.cs +++ b/Clippit/Word/DocumentAssembler.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Collections.Concurrent; using System.Text.RegularExpressions; using System.Xml; using System.Xml.Linq; @@ -16,6 +17,25 @@ namespace Clippit.Word { + /// + /// Handles a custom DocumentAssembler directive element during content replacement. + /// + /// + /// The custom element extracted from the template (e.g. <MyDirective Select="..." />), + /// including all attributes defined in the registered XSD schema. + /// + /// The current XML data-context node. + /// + /// The being assembled. The handler may add new parts or + /// relationships to this part if needed. + /// + /// + /// The replacement content: an , an IEnumerable<XNode>, + /// or null to silently remove the element. + /// Throwing an exception marks the document with a template error. + /// + public delegate object CustomAssemblerHandler(XElement directive, XElement data, OpenXmlPart part); + public static partial class DocumentAssembler { public static WmlDocument AssembleDocument(WmlDocument templateDoc, XmlDocument data, out bool templateError) @@ -57,6 +77,56 @@ public static WmlDocument AssembleDocument(WmlDocument templateDoc, XElement dat return ProcessEmbeddedDocuments(byteArray); } + // ── Custom handler registration ────────────────────────────────────────────────────────── + + private static readonly ConcurrentDictionary< + XName, + (PASchemaSet? Schema, CustomAssemblerHandler Handler) + > s_customHandlers = new(); + + /// + /// Registers a custom directive handler so DocumentAssembler can process + /// <ElementName .../> directives embedded in Word templates. + /// + /// + /// The local name of the custom XML element (e.g. "QrCode"). + /// Must not conflict with built-in names (Content, Table, Repeat, etc.). + /// + /// + /// Optional XSD fragment that validates the directive's attributes (same format as the + /// built-in schemas in s_paSchemaSets). Pass null to skip validation. + /// + /// The delegate that replaces the directive with real content. + public static void RegisterCustomHandler(string elementName, string? schemaXsd, CustomAssemblerHandler handler) + { + ArgumentNullException.ThrowIfNull(elementName); + ArgumentNullException.ThrowIfNull(handler); + if (s_paSchemaSets.ContainsKey(elementName)) + throw new ArgumentException( + $"'{elementName}' is a built-in DocumentAssembler element name and cannot be overridden.", + nameof(elementName) + ); + var schema = schemaXsd is not null ? new PASchemaSet(schemaXsd) : null; + s_customHandlers[(XName)elementName] = (schema, handler); + lock (s_aliasList) + { + if (!s_aliasList.Contains(elementName)) + s_aliasList.Add(elementName); + } + } + + /// + /// Removes a previously registered custom handler. + /// Silently does nothing if was never registered. + /// + public static void UnregisterCustomHandler(string elementName) + { + ArgumentNullException.ThrowIfNull(elementName); + s_customHandlers.TryRemove((XName)elementName, out _); + lock (s_aliasList) + s_aliasList.Remove(elementName); + } + private static WmlDocument ProcessEmbeddedDocuments(byte[] docData) { // use document builder named sources to deal with embedded documents initialise a list of sources @@ -216,6 +286,9 @@ private static void ProcessTemplatePart(XElement data, TemplateError te, OpenXml PA.DocumentTemplate, }; + private static bool IsMetaToForceToBlock(XName name) => + s_metaToForceToBlock.Contains(name) || s_customHandlers.ContainsKey(name); + private static object ForceBlockLevelAsAppropriate(XNode node, TemplateError te) { if (node is not XElement element) @@ -223,7 +296,7 @@ private static object ForceBlockLevelAsAppropriate(XNode node, TemplateError te) if (element.Name == W.p) { - var childMeta = element.Elements().Where(n => s_metaToForceToBlock.Contains(n.Name)).ToList(); + var childMeta = element.Elements().Where(n => IsMetaToForceToBlock(n.Name)).ToList(); if (childMeta.Count == 1) { var child = childMeta.First(); @@ -236,7 +309,7 @@ private static object ForceBlockLevelAsAppropriate(XNode node, TemplateError te) if (otherTextInParagraph != "") { var newPara = new XElement(element); - var newMeta = newPara.Elements().First(n => s_metaToForceToBlock.Contains(n.Name)); + var newMeta = newPara.Elements().First(n => IsMetaToForceToBlock(n.Name)); newMeta.ReplaceWith( ErrorHandler.CreateRunErrorMessage( "Error: Unmatched metadata can't be in paragraph with other text", @@ -824,7 +897,18 @@ private static string ValidatePerSchema(XElement element) { if (s_paSchemaSets.TryGetValue(element.Name, out var paSchemaSet) == false) { - return $"Invalid XML: {element.Name.LocalName} is not a valid element"; + // Check for a registered custom handler + if (s_customHandlers.TryGetValue(element.Name, out var entry)) + { + // Custom handler with no schema: skip validation + if (entry.Schema is null) + return null; + paSchemaSet = entry.Schema; + } + else + { + return $"Invalid XML: {element.Name.LocalName} is not a valid element"; + } } var d = new XDocument(element); @@ -1868,6 +1952,20 @@ OpenXmlPart part } } + // Invoke registered custom handlers + if (s_customHandlers.TryGetValue(element.Name, out var customEntry)) + { + try + { + var result = customEntry.Handler(element, data, part); + return result ?? Enumerable.Empty(); + } + catch (Exception e) + { + return element.CreateContextErrorMessage($"{element.Name.LocalName}: {e.Message}", templateError); + } + } + return new XElement( element.Name, element.Attributes(),