diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37788da..7a851fe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- **Header & Footer Support** - Process placeholders, conditionals, and loops in document headers and footers (#15)
+ - All header/footer types supported: Default, First Page, Even Page
+ - Same syntax and features as document body - no additional API calls needed
+ - Formatting is preserved in headers and footers
+
## [1.5.0] - 2026-02-13
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index c8da73c..15e2d6d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -189,7 +189,7 @@ The library uses a **visitor pattern** for processing Word documents, enabling:
2. **DocumentWalker** (Visitors/DocumentWalker.cs)
- Unified document traversal engine
- - Walks document tree (body, tables, rows, cells)
+ - Walks document tree (body, tables, rows, cells, headers, footers)
- Detects template elements (conditionals, loops, placeholders)
- Dispatches to appropriate visitors
@@ -216,10 +216,12 @@ The library uses a **visitor pattern** for processing Word documents, enabling:
├─▶ Create GlobalEvaluationContext(data)
├─▶ Create DocumentWalker
├─▶ Create visitor composite (conditional + loop + placeholder)
- └─▶ walker.Walk(document, composite, globalContext)
- ├─▶ Step 1: Detect & visit conditionals (deepest first)
- ├─▶ Step 2: Detect & visit loops
- └─▶ Step 3: Visit paragraphs for placeholders
+ ├─▶ walker.Walk(document, composite, globalContext)
+ │ ├─▶ Step 1: Detect & visit conditionals (deepest first)
+ │ ├─▶ Step 2: Detect & visit loops
+ │ └─▶ Step 3: Visit paragraphs for placeholders
+ └─▶ walker.WalkHeadersAndFooters(document, composite, globalContext)
+ └─▶ Same 3-step processing for each header/footer part
2. When LoopVisitor processes a loop:
├─▶ Resolve collection from context
diff --git a/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs b/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs
index 11118b3..9a9c1a0 100644
--- a/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs
+++ b/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs
@@ -536,11 +536,160 @@ public DocumentBuilder SetSubject(string subject)
return this;
}
+ ///
+ /// Adds a default header with the specified text.
+ ///
+ public DocumentBuilder AddHeader(string text) => AddHeader(text, HeaderFooterValues.Default);
+
+ ///
+ /// Adds a header with the specified text.
+ ///
+ public DocumentBuilder AddHeader(string text, HeaderFooterValues type)
+ {
+ return AddHeaderPart(type, null, text);
+ }
+
+ ///
+ /// Adds a header with multiple paragraphs.
+ ///
+ public DocumentBuilder AddHeaderWithParagraphs(HeaderFooterValues type, params string[] texts)
+ {
+ return AddHeaderPart(type, null, texts);
+ }
+
+ ///
+ /// Adds a default footer with the specified text.
+ ///
+ public DocumentBuilder AddFooter(string text) => AddFooter(text, HeaderFooterValues.Default);
+
+ ///
+ /// Adds a footer with the specified text.
+ ///
+ public DocumentBuilder AddFooter(string text, HeaderFooterValues type)
+ {
+ return AddFooterPart(type, null, text);
+ }
+
+ ///
+ /// Adds a footer with multiple paragraphs.
+ ///
+ public DocumentBuilder AddFooterWithParagraphs(HeaderFooterValues type, params string[] texts)
+ {
+ return AddFooterPart(type, null, texts);
+ }
+
+ ///
+ /// Adds a default header with the specified text and formatting.
+ ///
+ public DocumentBuilder AddHeader(string text, RunProperties formatting) => AddHeader(text, formatting, HeaderFooterValues.Default);
+
+ ///
+ /// Adds a header with the specified text and formatting.
+ ///
+ public DocumentBuilder AddHeader(string text, RunProperties formatting, HeaderFooterValues type)
+ {
+ return AddHeaderPart(type, formatting, text);
+ }
+
+ ///
+ /// Adds a default footer with the specified text and formatting.
+ ///
+ public DocumentBuilder AddFooter(string text, RunProperties formatting) => AddFooter(text, formatting, HeaderFooterValues.Default);
+
+ ///
+ /// Adds a footer with the specified text and formatting.
+ ///
+ public DocumentBuilder AddFooter(string text, RunProperties formatting, HeaderFooterValues type)
+ {
+ return AddFooterPart(type, formatting, text);
+ }
+
+ private DocumentBuilder AddHeaderPart(HeaderFooterValues type, RunProperties? formatting, params string[] texts)
+ {
+ MainDocumentPart mainPart = _document.MainDocumentPart!;
+ HeaderPart headerPart = mainPart.AddNewPart();
+ headerPart.Header = new Header();
+ AppendParagraphs(headerPart.Header, formatting, texts);
+ headerPart.Header.Save();
+
+ string partId = mainPart.GetIdOfPart(headerPart);
+ SectionProperties sectionProps = EnsureSectionProperties();
+
+ // Remove existing header reference of the same type to avoid duplicates
+ HeaderReference? existing = sectionProps.Elements()
+ .FirstOrDefault(r => r.Type?.Value == type || (type == HeaderFooterValues.Default && r.Type == null));
+ existing?.Remove();
+
+ sectionProps.Append(new HeaderReference { Type = type, Id = partId });
+ return this;
+ }
+
+ private DocumentBuilder AddFooterPart(HeaderFooterValues type, RunProperties? formatting, params string[] texts)
+ {
+ MainDocumentPart mainPart = _document.MainDocumentPart!;
+ FooterPart footerPart = mainPart.AddNewPart();
+ footerPart.Footer = new Footer();
+ AppendParagraphs(footerPart.Footer, formatting, texts);
+ footerPart.Footer.Save();
+
+ string partId = mainPart.GetIdOfPart(footerPart);
+ SectionProperties sectionProps = EnsureSectionProperties();
+
+ // Remove existing footer reference of the same type to avoid duplicates
+ FooterReference? existingFooter = sectionProps.Elements()
+ .FirstOrDefault(r => r.Type?.Value == type || (type == HeaderFooterValues.Default && r.Type == null));
+ existingFooter?.Remove();
+
+ sectionProps.Append(new FooterReference { Type = type, Id = partId });
+ return this;
+ }
+
+ private static void AppendParagraphs(OpenXmlCompositeElement container, RunProperties? formatting, string[] texts)
+ {
+ foreach (string text in texts)
+ {
+ Paragraph paragraph = new Paragraph();
+ Run run = new Run();
+ Text textElement = new Text(text) { Space = SpaceProcessingModeValues.Preserve };
+ run.Append(textElement);
+ if (formatting != null)
+ {
+ run.RunProperties = (RunProperties)formatting.CloneNode(true);
+ }
+
+ paragraph.Append(run);
+ container.Append(paragraph);
+ }
+ }
+
+ ///
+ /// Ensures the body has SectionProperties and returns it.
+ ///
+ private SectionProperties EnsureSectionProperties()
+ {
+ SectionProperties? sectionProps = _body.Elements().FirstOrDefault();
+ if (sectionProps == null)
+ {
+ sectionProps = new SectionProperties();
+ _body.Append(sectionProps);
+ }
+
+ return sectionProps;
+ }
+
///
/// Returns the document as a MemoryStream for processing.
///
public MemoryStream ToStream()
{
+ // Ensure SectionProperties is the last child of Body (OpenXML schema requirement)
+ SectionProperties? sectPr = _body.Elements().FirstOrDefault();
+ if (sectPr != null)
+ {
+ sectPr.Remove();
+ _body.Append(sectPr);
+ }
+
// Save and close the document
_document.MainDocumentPart!.Document.Save();
_document.Dispose();
diff --git a/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs b/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs
index a27b893..44ead26 100644
--- a/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs
+++ b/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs
@@ -569,6 +569,115 @@ public bool HasUpdateFieldsOnOpen()
///
public string? GetDocumentLastModifiedBy() => _document.PackageProperties.LastModifiedBy;
+ ///
+ /// Gets the text content of the default header.
+ ///
+ public string GetHeaderText() => GetHeaderText(HeaderFooterValues.Default);
+
+ ///
+ /// Gets the text content of the default footer.
+ ///
+ public string GetFooterText() => GetFooterText(HeaderFooterValues.Default);
+
+ ///
+ /// Gets the RunProperties from the first run in the default header.
+ ///
+ public RunProperties? GetHeaderRunProperties() => GetHeaderRunProperties(HeaderFooterValues.Default);
+
+ ///
+ /// Gets the RunProperties from the first run in the default footer.
+ ///
+ public RunProperties? GetFooterRunProperties() => GetFooterRunProperties(HeaderFooterValues.Default);
+
+ ///
+ /// Gets the text content of a header by type.
+ ///
+ public string GetHeaderText(HeaderFooterValues type)
+ {
+ HeaderPart headerPart = GetHeaderPart(type);
+ return headerPart.Header?.InnerText ?? string.Empty;
+ }
+
+ ///
+ /// Gets the text content of a footer by type.
+ ///
+ public string GetFooterText(HeaderFooterValues type)
+ {
+ FooterPart footerPart = GetFooterPart(type);
+ return footerPart.Footer?.InnerText ?? string.Empty;
+ }
+
+ ///
+ /// Gets all paragraph texts from a header by type.
+ ///
+ public List GetHeaderParagraphTexts(HeaderFooterValues type)
+ {
+ HeaderPart headerPart = GetHeaderPart(type);
+ return headerPart.Header?.Elements()
+ .Select(p => p.InnerText)
+ .ToList() ?? new List();
+ }
+
+ ///
+ /// Gets all paragraph texts from a footer by type.
+ ///
+ public List GetFooterParagraphTexts(HeaderFooterValues type)
+ {
+ FooterPart footerPart = GetFooterPart(type);
+ return footerPart.Footer?.Elements()
+ .Select(p => p.InnerText)
+ .ToList() ?? new List();
+ }
+
+ ///
+ /// Gets the RunProperties from the first run in the header.
+ ///
+ public RunProperties? GetHeaderRunProperties(HeaderFooterValues type)
+ {
+ HeaderPart headerPart = GetHeaderPart(type);
+ return headerPart.Header?.Descendants().FirstOrDefault()?.RunProperties;
+ }
+
+ ///
+ /// Gets the RunProperties from the first run in the footer.
+ ///
+ public RunProperties? GetFooterRunProperties(HeaderFooterValues type)
+ {
+ FooterPart footerPart = GetFooterPart(type);
+ return footerPart.Footer?.Descendants().FirstOrDefault()?.RunProperties;
+ }
+
+ private HeaderPart GetHeaderPart(HeaderFooterValues type)
+ {
+ return (HeaderPart)GetHeaderFooterPart(type, "Header");
+ }
+
+ private FooterPart GetFooterPart(HeaderFooterValues type)
+ {
+ return (FooterPart)GetHeaderFooterPart(type, "Footer");
+ }
+
+ private OpenXmlPart GetHeaderFooterPart(HeaderFooterValues type, string partName)
+ where TReference : HeaderFooterReferenceType
+ {
+ MainDocumentPart mainPart = _document.MainDocumentPart
+ ?? throw new InvalidOperationException("MainDocumentPart not found");
+
+ SectionProperties sectionProps = (mainPart.Document?.Body)
+ ?.Elements().FirstOrDefault()
+ ?? throw new InvalidOperationException("SectionProperties not found");
+
+ TReference? reference = sectionProps.Elements()
+ .FirstOrDefault(r => r.Type?.Value == type || (type == HeaderFooterValues.Default && r.Type == null));
+
+ if (reference?.Id?.Value == null)
+ {
+ throw new InvalidOperationException($"{partName} reference of type {type} not found");
+ }
+
+ return mainPart.GetPartById(reference.Id.Value);
+ }
+
public void Dispose()
{
_document?.Dispose();
diff --git a/TriasDev.Templify.Tests/Integration/HeaderFooterTests.cs b/TriasDev.Templify.Tests/Integration/HeaderFooterTests.cs
new file mode 100644
index 0000000..cf2ccc6
--- /dev/null
+++ b/TriasDev.Templify.Tests/Integration/HeaderFooterTests.cs
@@ -0,0 +1,489 @@
+// Copyright (c) 2025 TriasDev GmbH & Co. KG
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+
+using System.Globalization;
+using DocumentFormat.OpenXml.Wordprocessing;
+using TriasDev.Templify.Core;
+using TriasDev.Templify.Tests.Helpers;
+
+namespace TriasDev.Templify.Tests.Integration;
+
+///
+/// Integration tests for header and footer template processing.
+///
+public sealed class HeaderFooterTests
+{
+ [Fact]
+ public void ProcessTemplate_PlaceholderInHeader_ReplacesCorrectly()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddHeader("Company: {{CompanyName}}");
+ builder.AddParagraph("Body content");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["CompanyName"] = "Acme Corp"
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+ Assert.Equal(1, result.ReplacementCount);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ Assert.Equal("Company: Acme Corp", verifier.GetHeaderText());
+ }
+
+ [Fact]
+ public void ProcessTemplate_PlaceholderInFooter_ReplacesCorrectly()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddFooter("Page {{PageInfo}}");
+ builder.AddParagraph("Body content");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["PageInfo"] = "1 of 5"
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+ Assert.Equal(1, result.ReplacementCount);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ Assert.Equal("Page 1 of 5", verifier.GetFooterText());
+ }
+
+ [Fact]
+ public void ProcessTemplate_PlaceholdersInHeaderAndFooterAndBody_ReplacesAll()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddHeader("Header: {{Title}}");
+ builder.AddFooter("Footer: {{Author}}");
+ builder.AddParagraph("Body: {{Content}}");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Title"] = "My Document",
+ ["Author"] = "Jane Doe",
+ ["Content"] = "Hello World"
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+ Assert.Equal(3, result.ReplacementCount);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ Assert.Equal("Header: My Document", verifier.GetHeaderText());
+ Assert.Equal("Footer: Jane Doe", verifier.GetFooterText());
+ Assert.Equal("Body: Hello World", verifier.GetParagraphText(0));
+ }
+
+ [Fact]
+ public void ProcessTemplate_ConditionalInHeader_EvaluatesCorrectly()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddHeaderWithParagraphs(
+ HeaderFooterValues.Default,
+ "{{#if IsDraft}}DRAFT{{/if}}",
+ "{{CompanyName}}");
+ builder.AddParagraph("Body content");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["IsDraft"] = true,
+ ["CompanyName"] = "Acme Corp"
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ string headerText = verifier.GetHeaderText();
+ Assert.Contains("DRAFT", headerText);
+ Assert.Contains("Acme Corp", headerText);
+ }
+
+ [Fact]
+ public void ProcessTemplate_ConditionalInHeader_FalseCondition_RemovesBranch()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddHeaderWithParagraphs(
+ HeaderFooterValues.Default,
+ "{{#if IsDraft}}DRAFT{{/if}}",
+ "{{CompanyName}}");
+ builder.AddParagraph("Body content");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["IsDraft"] = false,
+ ["CompanyName"] = "Acme Corp"
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ string headerText = verifier.GetHeaderText();
+ Assert.DoesNotContain("DRAFT", headerText);
+ Assert.Contains("Acme Corp", headerText);
+ }
+
+ [Fact]
+ public void ProcessTemplate_LoopInFooter_ExpandsCorrectly()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddFooterWithParagraphs(
+ HeaderFooterValues.Default,
+ "{{#foreach Items}}",
+ "- {{Name}}",
+ "{{/foreach}}");
+ builder.AddParagraph("Body content");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Items"] = new List>
+ {
+ new Dictionary { ["Name"] = "Item A" },
+ new Dictionary { ["Name"] = "Item B" }
+ }
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ string footerText = verifier.GetFooterText();
+ Assert.Contains("Item A", footerText);
+ Assert.Contains("Item B", footerText);
+ }
+
+ [Fact]
+ public void ProcessTemplate_MultipleHeaderTypes_ReplacesAll()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddHeader("Default: {{Title}}", HeaderFooterValues.Default);
+ builder.AddHeader("First: {{Title}}", HeaderFooterValues.First);
+ builder.AddParagraph("Body content");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Title"] = "My Report"
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+ Assert.Equal(2, result.ReplacementCount);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ Assert.Equal("Default: My Report", verifier.GetHeaderText(HeaderFooterValues.Default));
+ Assert.Equal("First: My Report", verifier.GetHeaderText(HeaderFooterValues.First));
+ }
+
+ [Fact]
+ public void ProcessTemplate_FormattingPreservedInHeader_MaintainsFormatting()
+ {
+ // Arrange
+ RunProperties boldFormatting = DocumentBuilder.CreateFormatting(bold: true, color: "FF0000");
+
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddHeader("Company: {{CompanyName}}", boldFormatting);
+ builder.AddParagraph("Body content");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["CompanyName"] = "Acme Corp"
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ Assert.Equal("Company: Acme Corp", verifier.GetHeaderText());
+
+ RunProperties? headerProps = verifier.GetHeaderRunProperties();
+ DocumentVerifier.VerifyFormatting(headerProps, expectedBold: true, expectedColor: "FF0000");
+ }
+
+ [Fact]
+ public void ProcessTemplate_MultiplePlaceholdersInHeader_ReplacesAll()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddHeader("{{CompanyName}} - {{Department}}");
+ builder.AddParagraph("Body content");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["CompanyName"] = "Acme Corp",
+ ["Department"] = "Engineering"
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+ Assert.Equal(2, result.ReplacementCount);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ Assert.Equal("Acme Corp - Engineering", verifier.GetHeaderText());
+ }
+
+ [Fact]
+ public void ValidateTemplate_PlaceholderInHeader_DetectedInValidation()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddHeader("Company: {{CompanyName}}");
+ builder.AddParagraph("Body: {{Content}}");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Content"] = "Hello"
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+
+ // Act
+ ValidationResult result = processor.ValidateTemplate(templateStream, data);
+
+ // Assert
+ Assert.Contains("CompanyName", result.AllPlaceholders);
+ Assert.Contains("Content", result.AllPlaceholders);
+ Assert.Contains("CompanyName", result.MissingVariables);
+ }
+
+ [Fact]
+ public void ProcessTemplate_EvenPageFooter_ReplacesCorrectly()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddFooter("Default footer: {{Info}}", HeaderFooterValues.Default);
+ builder.AddFooter("Even footer: {{Info}}", HeaderFooterValues.Even);
+ builder.AddParagraph("Body content");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Info"] = "Confidential"
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+ Assert.Equal(2, result.ReplacementCount);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ Assert.Equal("Default footer: Confidential", verifier.GetFooterText(HeaderFooterValues.Default));
+ Assert.Equal("Even footer: Confidential", verifier.GetFooterText(HeaderFooterValues.Even));
+ }
+
+ [Fact]
+ public void ProcessTemplate_NestedPropertyInHeader_ReplacesCorrectly()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddHeader("Contact: {{Customer.Name}}");
+ builder.AddParagraph("Body content");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Customer"] = new Dictionary
+ {
+ ["Name"] = "Alice Smith"
+ }
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+ Assert.Equal(1, result.ReplacementCount);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ Assert.Equal("Contact: Alice Smith", verifier.GetHeaderText());
+ }
+
+ [Fact]
+ public void ProcessTemplate_FormattingPreservedInFooter_MaintainsFormatting()
+ {
+ // Arrange
+ RunProperties boldRedFormatting = DocumentBuilder.CreateFormatting(bold: true, color: "0000FF");
+
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddFooter("Footer: {{Info}}", boldRedFormatting);
+ builder.AddParagraph("Body content");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Info"] = "Confidential"
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ Assert.Equal("Footer: Confidential", verifier.GetFooterText());
+
+ RunProperties? footerProps = verifier.GetFooterRunProperties();
+ DocumentVerifier.VerifyFormatting(footerProps, expectedBold: true, expectedColor: "0000FF");
+ }
+
+ [Fact]
+ public void ProcessTemplate_HeaderOnlyNoFooter_ProcessesSuccessfully()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddHeader("Header: {{Title}}");
+ builder.AddParagraph("Body: {{Content}}");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Title"] = "Report",
+ ["Content"] = "Hello"
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+ Assert.Equal(2, result.ReplacementCount);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ Assert.Equal("Header: Report", verifier.GetHeaderText());
+ Assert.Equal("Body: Hello", verifier.GetParagraphText(0));
+ }
+
+ [Fact]
+ public void ProcessTemplate_MarkdownInHeader_AppliesFormatting()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddHeader("{{Title}}");
+ builder.AddParagraph("Body content");
+
+ MemoryStream templateStream = builder.ToStream();
+
+ Dictionary data = new Dictionary
+ {
+ ["Title"] = "This is **bold** text"
+ };
+
+ DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
+ MemoryStream outputStream = new MemoryStream();
+
+ // Act
+ ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);
+
+ // Assert
+ Assert.True(result.IsSuccess);
+
+ using DocumentVerifier verifier = new DocumentVerifier(outputStream);
+ string headerText = verifier.GetHeaderText();
+ Assert.Equal("This is bold text", headerText);
+ }
+}
diff --git a/TriasDev.Templify/ARCHITECTURE.md b/TriasDev.Templify/ARCHITECTURE.md
index d534b4c..b31fed8 100644
--- a/TriasDev.Templify/ARCHITECTURE.md
+++ b/TriasDev.Templify/ARCHITECTURE.md
@@ -834,8 +834,7 @@ object value → string representation
While the MVP is intentionally limited, the architecture supports future extensions:
### 1. Additional Replacers
-Create new replacer classes for:
-- Headers/Footers
+- ~~Headers/Footers~~ (implemented via `DocumentWalker.WalkHeadersAndFooters`)
- Text boxes
- Footnotes
- Custom XML parts
diff --git a/TriasDev.Templify/Core/DocumentTemplateProcessor.cs b/TriasDev.Templify/Core/DocumentTemplateProcessor.cs
index 293ba59..6b67d04 100644
--- a/TriasDev.Templify/Core/DocumentTemplateProcessor.cs
+++ b/TriasDev.Templify/Core/DocumentTemplateProcessor.cs
@@ -123,6 +123,9 @@ public ProcessingResult ProcessTemplate(
// Walk the document with the composite visitor
walker.Walk(document, composite, globalContext);
+ // Walk headers and footers with the same visitor pipeline
+ walker.WalkHeadersAndFooters(document, composite, globalContext);
+
// Apply UpdateFieldsOnOpen setting based on mode
bool shouldUpdateFields = _options.UpdateFieldsOnOpen switch
{
@@ -292,9 +295,16 @@ private static bool HasFields(WordprocessingDocument document)
return false;
}
- return document.MainDocumentPart.Document.Body
- .Descendants()
- .Any(fc =>
+ // Collect field codes from body, headers, and footers (lazy to short-circuit on first match)
+ IEnumerable fieldCodes = document.MainDocumentPart.Document.Body.Descendants()
+ .Concat(document.MainDocumentPart.HeaderParts
+ .Where(hp => hp.Header != null)
+ .SelectMany(hp => hp.Header!.Descendants()))
+ .Concat(document.MainDocumentPart.FooterParts
+ .Where(fp => fp.Footer != null)
+ .SelectMany(fp => fp.Footer!.Descendants()));
+
+ return fieldCodes.Any(fc =>
{
if (string.IsNullOrWhiteSpace(fc.Text))
{
diff --git a/TriasDev.Templify/Core/TemplateValidator.cs b/TriasDev.Templify/Core/TemplateValidator.cs
index 5776ec9..a8a44b4 100644
--- a/TriasDev.Templify/Core/TemplateValidator.cs
+++ b/TriasDev.Templify/Core/TemplateValidator.cs
@@ -76,10 +76,16 @@ public ValidationResult Validate(Stream templateStream, Dictionary allPlaceholders,
List errors)
{
- foreach (Table table in body.Elements())
+ ValidateTableRowLoopsInElements(body.Elements().ToList(), allPlaceholders, errors);
+ }
+
+ ///
+ /// Validates table row loops in a list of elements (used for headers/footers).
+ ///
+ private static void ValidateTableRowLoopsInElements(
+ List elements,
+ HashSet allPlaceholders,
+ List errors)
+ {
+ foreach (Table table in elements.OfType())
{
try
{
@@ -193,14 +210,7 @@ private static void ValidateTableRowLoops(
///
private static void FindAllPlaceholders(Body body, HashSet allPlaceholders)
{
- PlaceholderFinder placeholderFinder = new PlaceholderFinder();
- string bodyText = body.InnerText;
- IEnumerable foundPlaceholders = placeholderFinder.GetUniqueVariableNames(bodyText);
-
- foreach (string placeholder in foundPlaceholders)
- {
- allPlaceholders.Add(placeholder);
- }
+ FindAllPlaceholdersInElements(body.Elements().ToList(), allPlaceholders);
}
///
@@ -579,6 +589,89 @@ private static bool CanResolveInScope(
return resolver.TryResolveValue(data, placeholder, out _);
}
+ ///
+ /// Gets all element lists from header and footer parts in the document.
+ ///
+ private static IEnumerable> GetHeaderFooterElements(WordprocessingDocument document)
+ {
+ if (document.MainDocumentPart == null)
+ {
+ yield break;
+ }
+
+ foreach (HeaderPart headerPart in document.MainDocumentPart.HeaderParts)
+ {
+ if (headerPart.Header != null)
+ {
+ yield return headerPart.Header.Elements().ToList();
+ }
+ }
+
+ foreach (FooterPart footerPart in document.MainDocumentPart.FooterParts)
+ {
+ if (footerPart.Footer != null)
+ {
+ yield return footerPart.Footer.Elements().ToList();
+ }
+ }
+ }
+
+ ///
+ /// Validates template elements in all headers and footers.
+ ///
+ private static void ValidateHeadersAndFooters(
+ WordprocessingDocument document,
+ HashSet allPlaceholders,
+ List errors)
+ {
+ foreach (List elements in GetHeaderFooterElements(document))
+ {
+ _ = ValidateConditionals(elements, allPlaceholders, errors);
+ ValidateLoops(elements, allPlaceholders, errors);
+ ValidateTableRowLoopsInElements(elements, allPlaceholders, errors);
+ FindAllPlaceholdersInElements(elements, allPlaceholders);
+ }
+ }
+
+ ///
+ /// Validates missing variables in headers and footers.
+ ///
+ private static void ValidateHeaderFooterMissingVariables(
+ WordprocessingDocument document,
+ Dictionary data,
+ HashSet allPlaceholders,
+ HashSet missingVariables,
+ List warnings,
+ List errors,
+ bool warnOnEmptyLoopCollections)
+ {
+ ValueResolver resolver = new ValueResolver();
+
+ foreach (List elements in GetHeaderFooterElements(document))
+ {
+ Stack<(string CollectionName, string? IterationVariableName, HashSet Properties)> loopStack =
+ new Stack<(string CollectionName, string? IterationVariableName, HashSet Properties)>();
+ ValidatePlaceholdersInScope(elements, loopStack, data, allPlaceholders, missingVariables, warnings, errors, resolver, warnOnEmptyLoopCollections);
+ }
+ }
+
+ ///
+ /// Finds all regular placeholders in a list of elements.
+ ///
+ private static void FindAllPlaceholdersInElements(List elements, HashSet allPlaceholders)
+ {
+ PlaceholderFinder placeholderFinder = new PlaceholderFinder();
+ foreach (OpenXmlElement element in elements)
+ {
+ string text = element.InnerText;
+ IEnumerable foundPlaceholders = placeholderFinder.GetUniqueVariableNames(text);
+ foreach (string placeholder in foundPlaceholders)
+ {
+ allPlaceholders.Add(placeholder);
+ }
+ }
+ }
+
///
/// Finds all placeholders in the given elements, excluding content inside nested loops.
///
diff --git a/TriasDev.Templify/Examples.md b/TriasDev.Templify/Examples.md
index 376c916..047e360 100644
--- a/TriasDev.Templify/Examples.md
+++ b/TriasDev.Templify/Examples.md
@@ -20,6 +20,7 @@ This document provides practical examples for common use cases.
14. [Web Application Integration](#web-application-integration)
15. [Report Generation](#report-generation)
16. [Document Properties](#document-properties)
+17. [Headers and Footers](#headers-and-footers)
---
@@ -2099,6 +2100,104 @@ var optionsGB = new PlaceholderReplacementOptions
---
+## Headers and Footers
+
+Headers and footers support the same template syntax as the document body. No additional API calls or configuration are needed - `ProcessTemplate` automatically processes all headers and footers.
+
+### Placeholders in Headers
+
+**Template header:**
+```
+{{CompanyName}} | Confidential
+```
+
+**Data:**
+```csharp
+var data = new Dictionary
+{
+ ["CompanyName"] = "Acme Corp"
+};
+```
+
+**Result header:**
+```
+Acme Corp | Confidential
+```
+
+### Conditional in Footer
+
+**Template footer:**
+```
+{{#if IsDraft}}DRAFT - For internal use only{{#else}}Final Version{{/if}} | Page {{PageNumber}}
+```
+
+**Data:**
+```csharp
+var data = new Dictionary
+{
+ ["IsDraft"] = true,
+ ["PageNumber"] = "1"
+};
+```
+
+**Result footer:**
+```
+DRAFT - For internal use only | Page 1
+```
+
+### Loop in Header
+
+**Template header:**
+```
+{{#foreach Authors}}{{Name}}{{#if @last}}{{#else}}, {{/if}}{{/foreach}}
+```
+
+**Data:**
+```csharp
+var data = new Dictionary
+{
+ ["Authors"] = new List