Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions Clippit.Tests/Word/DocumentAssemblerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/// <summary>DA500 — a registered custom handler is invoked and its return value replaces the directive.</summary>
[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("<Root><Name>Alice</Name></Root>");

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);
}
}

/// <summary>DA501 — a custom handler that throws maps to a template error.</summary>
[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("<Root/>");

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);
}
}

/// <summary>DA502 — a handler returning null silently removes the element.</summary>
[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("<Root/>");

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);
}
}

/// <summary>DA503 — after UnregisterCustomHandler the element is treated as invalid XML.</summary>
[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("<Root/>");

var result = DocumentAssembler.AssembleDocument(wmlTemplate, xmlData, out var hasError);

await Assert.That(hasError).IsTrue();
}

/// <summary>DA504 — a custom handler with a schema validates directive attributes.</summary>
[Test]
public async Task DA504_CustomHandler_SchemaValidationRejectsInvalidAttributes()
{
const string elementName = "DA504_SchemaHandler";
const string schema =
@"<xs:schema attributeFormDefault='unqualified' elementFormDefault='qualified'
xmlns:xs='http://www.w3.org/2001/XMLSchema'>
<xs:element name='DA504_SchemaHandler'>
<xs:complexType>
<xs:attribute name='Select' type='xs:string' use='required' />
</xs:complexType>
</xs:element>
</xs: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("<Root><Name>Schema Test</Name></Root>");

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);
}
}
}
104 changes: 101 additions & 3 deletions Clippit/Word/DocumentAssembler.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,6 +17,25 @@

namespace Clippit.Word
{
/// <summary>
/// Handles a custom DocumentAssembler directive element during content replacement.
/// </summary>
/// <param name="directive">
/// The custom element extracted from the template (e.g. <c>&lt;MyDirective Select="..." /&gt;</c>),
/// including all attributes defined in the registered XSD schema.
/// </param>
/// <param name="data">The current XML data-context node.</param>
/// <param name="part">
/// The <see cref="OpenXmlPart"/> being assembled. The handler may add new parts or
/// relationships to this part if needed.
/// </param>
/// <returns>
/// The replacement content: an <see cref="XNode"/>, an <c>IEnumerable&lt;XNode&gt;</c>,
/// or <c>null</c> to silently remove the element.
/// Throwing an exception marks the document with a template error.
/// </returns>
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)
Expand Down Expand Up @@ -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();

/// <summary>
/// Registers a custom directive handler so DocumentAssembler can process
/// <c>&lt;ElementName .../&gt;</c> directives embedded in Word templates.
/// </summary>
/// <param name="elementName">
/// The local name of the custom XML element (e.g. <c>"QrCode"</c>).
/// Must not conflict with built-in names (<c>Content</c>, <c>Table</c>, <c>Repeat</c>, etc.).
/// </param>
/// <param name="schemaXsd">
/// Optional XSD fragment that validates the directive's attributes (same format as the
/// built-in schemas in <c>s_paSchemaSets</c>). Pass <c>null</c> to skip validation.
/// </param>
/// <param name="handler">The delegate that replaces the directive with real content.</param>
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);
}
}

/// <summary>
/// Removes a previously registered custom handler.
/// Silently does nothing if <paramref name="elementName"/> was never registered.
/// </summary>
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
Expand Down Expand Up @@ -216,14 +286,17 @@ 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)
return node;

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();
Expand All @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<XNode>();
}
catch (Exception e)
{
return element.CreateContextErrorMessage($"{element.Name.LocalName}: {e.Message}", templateError);
}
}

return new XElement(
element.Name,
element.Attributes(),
Expand Down
Loading