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 + { + new Dictionary { ["Name"] = "Alice" }, + new Dictionary { ["Name"] = "Bob" } + } +}; +``` + +**Result header:** +``` +Alice, Bob +``` + +### Supported Header/Footer Types + +All Word header/footer types are processed: +- **Default** - Standard header/footer for most pages +- **First Page** - Header/footer for the first page only +- **Even Page** - Header/footer for even-numbered pages + +### Code Example + +```csharp +// No special configuration needed - headers/footers are processed automatically +var processor = new DocumentTemplateProcessor(); + +using var templateStream = File.OpenRead("template.docx"); +using var outputStream = File.Create("output.docx"); + +var data = new Dictionary +{ + ["CompanyName"] = "Acme Corp", + ["DocumentTitle"] = "Annual Report", + ["IsDraft"] = false +}; + +// This processes body, tables, headers, AND footers +var result = processor.ProcessTemplate(templateStream, outputStream, data); +``` + +--- + ## Troubleshooting Common Issues ### Placeholder Not Replaced @@ -2134,7 +2233,7 @@ var optionsGB = new PlaceholderReplacementOptions **Verify**: 1. Placeholder syntax is correct -2. Table is in document body (not header/footer - not supported in MVP) +2. Table is in document body, header, or footer 3. Check result.MissingVariables for clues --- diff --git a/TriasDev.Templify/README.md b/TriasDev.Templify/README.md index 4b6d406..bf5f4fc 100644 --- a/TriasDev.Templify/README.md +++ b/TriasDev.Templify/README.md @@ -21,6 +21,7 @@ Templify is built on the Microsoft OpenXML SDK and provides a straightforward AP - **Smart value conversion**: Automatically converts numbers, dates, booleans to readable strings - **Localization support**: Format specifiers adapt to cultures (en, de, fr, es, it, pt) - **Table support**: Replace placeholders in table cells, repeat table rows, conditional table rows +- **Header & footer support**: Placeholders, conditionals, and loops in document headers and footers - **Configurable behavior**: Control what happens when variables are missing - **No Word required**: Pure OpenXML processing, no COM automation - **.NET 9**: Built with modern .NET features @@ -1147,12 +1148,12 @@ For complete documentation, see the [Boolean Expressions Guide](../docs/guides/b ## Supported Document Locations -Current MVP supports: +Currently supported: - Document body paragraphs - Table cells +- Headers and footers (Default, First, Even) Future versions may include: -- Headers and footers - Text boxes - Footnotes/endnotes - Custom XML parts diff --git a/TriasDev.Templify/REFACTORING.md b/TriasDev.Templify/REFACTORING.md index 47abc71..37034ca 100644 --- a/TriasDev.Templify/REFACTORING.md +++ b/TriasDev.Templify/REFACTORING.md @@ -249,7 +249,7 @@ Output Document (Stream) **Examples of difficult-to-add features**: - Custom functions: `{{#if FormatDate(OrderDate) = "2025-01-01"}}` - Filter expressions: `{{#foreach Orders where Amount > 1000}}` -- Headers/footers: Would need separate code path +- ~~Headers/footers: Would need separate code path~~ (implemented - reuses existing visitor pipeline via `DocumentWalker.WalkHeadersAndFooters`) - Partial templates: `{{> include "header.docx"}}` - Custom blocks: `{{#custom MyBlock}}...{{/custom}}` diff --git a/TriasDev.Templify/TODO.md b/TriasDev.Templify/TODO.md index ec3db92..c7ea5d4 100644 --- a/TriasDev.Templify/TODO.md +++ b/TriasDev.Templify/TODO.md @@ -128,7 +128,7 @@ #### Medium Priority -- [ ] Add support for headers and footers +- [x] Add support for headers and footers - [ ] Add support for text boxes - [ ] Add support for footnotes/endnotes - [ ] Add support for custom XML parts diff --git a/TriasDev.Templify/Visitors/DocumentWalker.cs b/TriasDev.Templify/Visitors/DocumentWalker.cs index cfc23ee..923e6fb 100644 --- a/TriasDev.Templify/Visitors/DocumentWalker.cs +++ b/TriasDev.Templify/Visitors/DocumentWalker.cs @@ -58,6 +58,41 @@ public void Walk( WalkElements(elements, visitor, context); } + /// + /// Walks through all headers and footers in the document and visits template elements. + /// + /// The Word document to walk. + /// The visitor that will process detected elements. + /// The evaluation context for variable resolution. + public void WalkHeadersAndFooters( + WordprocessingDocument document, + ITemplateElementVisitor visitor, + IEvaluationContext context) + { + if (document?.MainDocumentPart == null) + { + return; + } + + foreach (HeaderPart headerPart in document.MainDocumentPart.HeaderParts) + { + if (headerPart.Header != null) + { + List elements = headerPart.Header.Elements().ToList(); + WalkElements(elements, visitor, context); + } + } + + foreach (FooterPart footerPart in document.MainDocumentPart.FooterParts) + { + if (footerPart.Footer != null) + { + List elements = footerPart.Footer.Elements().ToList(); + WalkElements(elements, visitor, context); + } + } + } + /// /// Walks through a list of elements and visits template constructs. /// diff --git a/docs/for-developers/quick-start.md b/docs/for-developers/quick-start.md index fa037d6..8cb1f36 100644 --- a/docs/for-developers/quick-start.md +++ b/docs/for-developers/quick-start.md @@ -58,6 +58,10 @@ var processor = new DocumentTemplateProcessor(options); | `TextReplacements` | dictionary | `null` | Text replacement lookup table | | `DocumentProperties` | `DocumentProperties?` | `null` | Metadata properties to set on output document | +## Headers and Footers + +`ProcessTemplate` automatically processes all headers and footers in the document - no additional API calls or configuration needed. The same visitor pipeline (placeholders, conditionals, loops) is applied to every header and footer part (Default, First Page, Even Page). + ## Update Fields on Open (TOC Support) When templates contain Table of Contents (TOC) or other dynamic fields, and content changes during processing (via conditionals or loops), page numbers become stale. diff --git a/docs/for-template-authors/headers-footers.md b/docs/for-template-authors/headers-footers.md new file mode 100644 index 0000000..3ce69c8 --- /dev/null +++ b/docs/for-template-authors/headers-footers.md @@ -0,0 +1,89 @@ +# Headers & Footers + +Templify processes headers and footers using the same template syntax as the document body. All placeholders, conditionals, and loops work identically in headers and footers. + +## Supported Header/Footer Types + +All Word header and footer types are supported: + +| Type | Description | +|------|-------------| +| **Default** | Standard header/footer for most pages | +| **First Page** | Header/footer shown only on the first page | +| **Even Page** | Header/footer for even-numbered pages | + +## No Additional Configuration Needed + +`ProcessTemplate` automatically processes all headers and footers. There is no flag to enable or additional API call to make. + +```csharp +var processor = new DocumentTemplateProcessor(); +var result = processor.ProcessTemplate(templateStream, outputStream, data); +// Headers and footers are already processed! +``` + +## Examples + +### Placeholder in Header + +Place `{{CompanyName}}` in your Word document header. It will be replaced just like any body placeholder. + +**Template header:** `{{CompanyName}} - Confidential` + +**JSON data:** +```json +{ + "CompanyName": "Acme Corp" +} +``` + +**Result:** `Acme Corp - Confidential` + +### Conditional in Footer + +Use conditionals to show different footer text based on data. + +**Template footer:** +``` +{{#if IsDraft}}DRAFT - For internal use only{{#else}}Final Version{{/if}} +``` + +**JSON data:** +```json +{ + "IsDraft": true +} +``` + +**Result:** `DRAFT - For internal use only` + +### Loop in Header + +Loops work in headers too - useful for listing authors, departments, etc. + +**Template header:** +``` +{{#foreach Authors}}{{Name}}{{#if @last}}{{#else}}, {{/if}}{{/foreach}} +``` + +**JSON data:** +```json +{ + "Authors": [ + { "Name": "Alice" }, + { "Name": "Bob" } + ] +} +``` + +**Result:** `Alice, Bob` + +## Formatting Preservation + +Formatting in headers and footers is preserved during replacement, just as in the document body. If your placeholder is bold, the replacement text will also be bold. + +## Tips + +- Use **First Page** headers/footers for cover pages with different branding +- Combine conditionals with document-level flags (e.g., `IsDraft`, `IsConfidential`) to control header/footer content +- Loop metadata (`@index`, `@first`, `@last`, `@count`) works in headers and footers diff --git a/mkdocs.yml b/mkdocs.yml index d5410d0..71b2ee2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -79,6 +79,7 @@ nav: - Format Specifiers: for-template-authors/format-specifiers.md - Boolean Expressions: for-template-authors/boolean-expressions.md - Best Practices: for-template-authors/best-practices.md + - Headers & Footers: for-template-authors/headers-footers.md - Examples Gallery: for-template-authors/examples-gallery.md - For Developers: - Quick Start: for-developers/quick-start.md