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(),