From 4601462653593c7afa020e74325610f0125ca9f8 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Thu, 5 Mar 2026 14:18:21 +0100 Subject: [PATCH 1/7] feat: add support for headers and footers (#15) Process placeholders, conditionals, and loops in document headers and footers using the same visitor pipeline as the body. Also extends template validation and field detection to cover header/footer parts. --- .../Helpers/DocumentBuilder.cs | 161 ++++++++ .../Helpers/DocumentVerifier.cs | 164 ++++++++ .../Integration/HeaderFooterTests.cs | 364 ++++++++++++++++++ .../Core/DocumentTemplateProcessor.cs | 27 +- TriasDev.Templify/Core/TemplateValidator.cs | 103 ++++- TriasDev.Templify/Visitors/DocumentWalker.cs | 35 ++ 6 files changed, 850 insertions(+), 4 deletions(-) create mode 100644 TriasDev.Templify.Tests/Integration/HeaderFooterTests.cs diff --git a/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs b/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs index 11118b3..147dc56 100644 --- a/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs +++ b/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs @@ -536,6 +536,167 @@ 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) + { + MainDocumentPart mainPart = _document.MainDocumentPart!; + + HeaderPart headerPart = mainPart.AddNewPart(); + Header header = new Header(); + Paragraph paragraph = new Paragraph(); + Run run = new Run(); + Text textElement = new Text(text) { Space = SpaceProcessingModeValues.Preserve }; + run.Append(textElement); + paragraph.Append(run); + header.Append(paragraph); + headerPart.Header = header; + headerPart.Header.Save(); + + string headerPartId = mainPart.GetIdOfPart(headerPart); + EnsureSectionProperties().Append(new HeaderReference { Type = type, Id = headerPartId }); + + return this; + } + + /// + /// Adds a header with multiple paragraphs. + /// + public DocumentBuilder AddHeaderWithParagraphs(HeaderFooterValues type, params string[] texts) + { + MainDocumentPart mainPart = _document.MainDocumentPart!; + + HeaderPart headerPart = mainPart.AddNewPart(); + Header header = new Header(); + + foreach (string text in texts) + { + Paragraph paragraph = new Paragraph(); + Run run = new Run(); + Text textElement = new Text(text) { Space = SpaceProcessingModeValues.Preserve }; + run.Append(textElement); + paragraph.Append(run); + header.Append(paragraph); + } + + headerPart.Header = header; + headerPart.Header.Save(); + + string headerPartId = mainPart.GetIdOfPart(headerPart); + EnsureSectionProperties().Append(new HeaderReference { Type = type, Id = headerPartId }); + + return this; + } + + /// + /// 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) + { + MainDocumentPart mainPart = _document.MainDocumentPart!; + + FooterPart footerPart = mainPart.AddNewPart(); + Footer footer = new Footer(); + Paragraph paragraph = new Paragraph(); + Run run = new Run(); + Text textElement = new Text(text) { Space = SpaceProcessingModeValues.Preserve }; + run.Append(textElement); + paragraph.Append(run); + footer.Append(paragraph); + footerPart.Footer = footer; + footerPart.Footer.Save(); + + string footerPartId = mainPart.GetIdOfPart(footerPart); + EnsureSectionProperties().Append(new FooterReference { Type = type, Id = footerPartId }); + + return this; + } + + /// + /// Adds a footer with multiple paragraphs. + /// + public DocumentBuilder AddFooterWithParagraphs(HeaderFooterValues type, params string[] texts) + { + MainDocumentPart mainPart = _document.MainDocumentPart!; + + FooterPart footerPart = mainPart.AddNewPart(); + Footer footer = new Footer(); + + foreach (string text in texts) + { + Paragraph paragraph = new Paragraph(); + Run run = new Run(); + Text textElement = new Text(text) { Space = SpaceProcessingModeValues.Preserve }; + run.Append(textElement); + paragraph.Append(run); + footer.Append(paragraph); + } + + footerPart.Footer = footer; + footerPart.Footer.Save(); + + string footerPartId = mainPart.GetIdOfPart(footerPart); + EnsureSectionProperties().Append(new FooterReference { Type = type, Id = footerPartId }); + + return this; + } + + /// + /// 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) + { + MainDocumentPart mainPart = _document.MainDocumentPart!; + + HeaderPart headerPart = mainPart.AddNewPart(); + Header header = new Header(); + Paragraph paragraph = new Paragraph(); + Run run = new Run(); + Text textElement = new Text(text) { Space = SpaceProcessingModeValues.Preserve }; + run.Append(textElement); + run.RunProperties = (RunProperties)formatting.CloneNode(true); + paragraph.Append(run); + header.Append(paragraph); + headerPart.Header = header; + headerPart.Header.Save(); + + string headerPartId = mainPart.GetIdOfPart(headerPart); + EnsureSectionProperties().Append(new HeaderReference { Type = type, Id = headerPartId }); + + return this; + } + + /// + /// 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. /// diff --git a/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs b/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs index a27b893..b3e0c04 100644 --- a/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs +++ b/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs @@ -569,6 +569,170 @@ 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 text content of a header by type. + /// + public string GetHeaderText(HeaderFooterValues type) + { + MainDocumentPart mainPart = _document.MainDocumentPart + ?? throw new InvalidOperationException("MainDocumentPart not found"); + + Body body = mainPart.Document?.Body + ?? throw new InvalidOperationException("Document body not found"); + + SectionProperties? sectionProps = body.Elements().FirstOrDefault(); + if (sectionProps == null) + { + throw new InvalidOperationException("SectionProperties not found"); + } + + HeaderReference? headerRef = sectionProps.Elements() + .FirstOrDefault(hr => hr.Type?.Value == type); + + if (headerRef?.Id?.Value == null) + { + throw new InvalidOperationException($"Header reference of type {type} not found"); + } + + HeaderPart headerPart = (HeaderPart)mainPart.GetPartById(headerRef.Id.Value); + return headerPart.Header?.InnerText ?? string.Empty; + } + + /// + /// Gets the text content of a footer by type. + /// + public string GetFooterText(HeaderFooterValues type) + { + MainDocumentPart mainPart = _document.MainDocumentPart + ?? throw new InvalidOperationException("MainDocumentPart not found"); + + Body body = mainPart.Document?.Body + ?? throw new InvalidOperationException("Document body not found"); + + SectionProperties? sectionProps = body.Elements().FirstOrDefault(); + if (sectionProps == null) + { + throw new InvalidOperationException("SectionProperties not found"); + } + + FooterReference? footerRef = sectionProps.Elements() + .FirstOrDefault(fr => fr.Type?.Value == type); + + if (footerRef?.Id?.Value == null) + { + throw new InvalidOperationException($"Footer reference of type {type} not found"); + } + + FooterPart footerPart = (FooterPart)mainPart.GetPartById(footerRef.Id.Value); + return footerPart.Footer?.InnerText ?? string.Empty; + } + + /// + /// Gets all paragraph texts from a header by type. + /// + public List GetHeaderParagraphTexts(HeaderFooterValues type) + { + MainDocumentPart mainPart = _document.MainDocumentPart + ?? throw new InvalidOperationException("MainDocumentPart not found"); + + Body body = mainPart.Document?.Body + ?? throw new InvalidOperationException("Document body not found"); + + SectionProperties? sectionProps = body.Elements().FirstOrDefault(); + if (sectionProps == null) + { + throw new InvalidOperationException("SectionProperties not found"); + } + + HeaderReference? headerRef = sectionProps.Elements() + .FirstOrDefault(hr => hr.Type?.Value == type); + + if (headerRef?.Id?.Value == null) + { + throw new InvalidOperationException($"Header reference of type {type} not found"); + } + + HeaderPart headerPart = (HeaderPart)mainPart.GetPartById(headerRef.Id.Value); + 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) + { + MainDocumentPart mainPart = _document.MainDocumentPart + ?? throw new InvalidOperationException("MainDocumentPart not found"); + + Body body = mainPart.Document?.Body + ?? throw new InvalidOperationException("Document body not found"); + + SectionProperties? sectionProps = body.Elements().FirstOrDefault(); + if (sectionProps == null) + { + throw new InvalidOperationException("SectionProperties not found"); + } + + FooterReference? footerRef = sectionProps.Elements() + .FirstOrDefault(fr => fr.Type?.Value == type); + + if (footerRef?.Id?.Value == null) + { + throw new InvalidOperationException($"Footer reference of type {type} not found"); + } + + FooterPart footerPart = (FooterPart)mainPart.GetPartById(footerRef.Id.Value); + 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) + { + MainDocumentPart mainPart = _document.MainDocumentPart + ?? throw new InvalidOperationException("MainDocumentPart not found"); + + Body body = mainPart.Document?.Body + ?? throw new InvalidOperationException("Document body not found"); + + SectionProperties? sectionProps = body.Elements().FirstOrDefault(); + if (sectionProps == null) + { + return null; + } + + HeaderReference? headerRef = sectionProps.Elements() + .FirstOrDefault(hr => hr.Type?.Value == type); + + if (headerRef?.Id?.Value == null) + { + return null; + } + + HeaderPart headerPart = (HeaderPart)mainPart.GetPartById(headerRef.Id.Value); + return headerPart.Header?.Descendants().FirstOrDefault()?.RunProperties; + } + 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..ded9915 --- /dev/null +++ b/TriasDev.Templify.Tests/Integration/HeaderFooterTests.cs @@ -0,0 +1,364 @@ +// 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)); + } +} diff --git a/TriasDev.Templify/Core/DocumentTemplateProcessor.cs b/TriasDev.Templify/Core/DocumentTemplateProcessor.cs index 293ba59..48a654f 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,27 @@ private static bool HasFields(WordprocessingDocument document) return false; } - return document.MainDocumentPart.Document.Body - .Descendants() - .Any(fc => + // Collect field codes from body, headers, and footers + IEnumerable fieldCodes = document.MainDocumentPart.Document.Body + .Descendants(); + + foreach (HeaderPart headerPart in document.MainDocumentPart.HeaderParts) + { + if (headerPart.Header != null) + { + fieldCodes = fieldCodes.Concat(headerPart.Header.Descendants()); + } + } + + foreach (FooterPart footerPart in document.MainDocumentPart.FooterParts) + { + if (footerPart.Footer != null) + { + fieldCodes = fieldCodes.Concat(footerPart.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..e16d663 100644 --- a/TriasDev.Templify/Core/TemplateValidator.cs +++ b/TriasDev.Templify/Core/TemplateValidator.cs @@ -76,10 +76,16 @@ public ValidationResult Validate(Stream templateStream, Dictionary + /// Validates template elements in all headers and footers. + /// + private static void ValidateHeadersAndFooters( + WordprocessingDocument document, + HashSet allPlaceholders, + List errors) + { + if (document.MainDocumentPart == null) + { + return; + } + + foreach (HeaderPart headerPart in document.MainDocumentPart.HeaderParts) + { + if (headerPart.Header != null) + { + List headerElements = headerPart.Header.Elements().ToList(); + _ = ValidateConditionals(headerElements, allPlaceholders, errors); + ValidateLoops(headerElements, allPlaceholders, errors); + FindAllPlaceholdersInElements(headerElements, allPlaceholders); + } + } + + foreach (FooterPart footerPart in document.MainDocumentPart.FooterParts) + { + if (footerPart.Footer != null) + { + List footerElements = footerPart.Footer.Elements().ToList(); + _ = ValidateConditionals(footerElements, allPlaceholders, errors); + ValidateLoops(footerElements, allPlaceholders, errors); + FindAllPlaceholdersInElements(footerElements, 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) + { + if (document.MainDocumentPart == null) + { + return; + } + + ValueResolver resolver = new ValueResolver(); + + foreach (HeaderPart headerPart in document.MainDocumentPart.HeaderParts) + { + if (headerPart.Header != null) + { + List elements = headerPart.Header.Elements().ToList(); + 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); + } + } + + foreach (FooterPart footerPart in document.MainDocumentPart.FooterParts) + { + if (footerPart.Footer != null) + { + List elements = footerPart.Footer.Elements().ToList(); + 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/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. /// From 09e143712c7d81dfc86f6ae78db3133d50df65a9 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Thu, 5 Mar 2026 14:36:22 +0100 Subject: [PATCH 2/7] docs: document header/footer support across all documentation Update README, Examples, ARCHITECTURE, REFACTORING, TODO, CLAUDE.md, CHANGELOG, mkdocs, and developer docs to reflect the new header/footer processing feature. Add new docs page for template authors and additional integration tests for nested properties, formatting preservation, and markdown in headers/footers. --- CHANGELOG.md | 6 + CLAUDE.md | 12 +- .../Helpers/DocumentBuilder.cs | 30 +++++ .../Helpers/DocumentVerifier.cs | 38 +++++- .../Integration/HeaderFooterTests.cs | 125 ++++++++++++++++++ TriasDev.Templify/ARCHITECTURE.md | 3 +- .../Core/DocumentTemplateProcessor.cs | 8 +- TriasDev.Templify/Examples.md | 101 +++++++++++++- TriasDev.Templify/README.md | 5 +- TriasDev.Templify/REFACTORING.md | 2 +- TriasDev.Templify/TODO.md | 2 +- docs/for-developers/quick-start.md | 4 + docs/for-template-authors/headers-footers.md | 89 +++++++++++++ mkdocs.yml | 1 + 14 files changed, 408 insertions(+), 18 deletions(-) create mode 100644 docs/for-template-authors/headers-footers.md 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 147dc56..fe28d64 100644 --- a/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs +++ b/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs @@ -682,6 +682,36 @@ public DocumentBuilder AddHeader(string text, RunProperties formatting, HeaderFo return this; } + /// + /// 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) + { + MainDocumentPart mainPart = _document.MainDocumentPart!; + + FooterPart footerPart = mainPart.AddNewPart(); + Footer footer = new Footer(); + Paragraph paragraph = new Paragraph(); + Run run = new Run(); + Text textElement = new Text(text) { Space = SpaceProcessingModeValues.Preserve }; + run.Append(textElement); + run.RunProperties = (RunProperties)formatting.CloneNode(true); + paragraph.Append(run); + footer.Append(paragraph); + footerPart.Footer = footer; + footerPart.Footer.Save(); + + string footerPartId = mainPart.GetIdOfPart(footerPart); + EnsureSectionProperties().Append(new FooterReference { Type = type, Id = footerPartId }); + + return this; + } + /// /// Ensures the body has SectionProperties and returns it. /// diff --git a/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs b/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs index b3e0c04..d808f7d 100644 --- a/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs +++ b/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs @@ -718,7 +718,7 @@ public List GetFooterParagraphTexts(HeaderFooterValues type) SectionProperties? sectionProps = body.Elements().FirstOrDefault(); if (sectionProps == null) { - return null; + throw new InvalidOperationException("SectionProperties not found"); } HeaderReference? headerRef = sectionProps.Elements() @@ -726,13 +726,47 @@ public List GetFooterParagraphTexts(HeaderFooterValues type) if (headerRef?.Id?.Value == null) { - return null; + throw new InvalidOperationException($"Header reference of type {type} not found"); } HeaderPart headerPart = (HeaderPart)mainPart.GetPartById(headerRef.Id.Value); return headerPart.Header?.Descendants().FirstOrDefault()?.RunProperties; } + /// + /// Gets the RunProperties from the first run in the default footer. + /// + public RunProperties? GetFooterRunProperties() => GetFooterRunProperties(HeaderFooterValues.Default); + + /// + /// Gets the RunProperties from the first run in the footer. + /// + public RunProperties? GetFooterRunProperties(HeaderFooterValues type) + { + MainDocumentPart mainPart = _document.MainDocumentPart + ?? throw new InvalidOperationException("MainDocumentPart not found"); + + Body body = mainPart.Document?.Body + ?? throw new InvalidOperationException("Document body not found"); + + SectionProperties? sectionProps = body.Elements().FirstOrDefault(); + if (sectionProps == null) + { + throw new InvalidOperationException("SectionProperties not found"); + } + + FooterReference? footerRef = sectionProps.Elements() + .FirstOrDefault(fr => fr.Type?.Value == type); + + if (footerRef?.Id?.Value == null) + { + throw new InvalidOperationException($"Footer reference of type {type} not found"); + } + + FooterPart footerPart = (FooterPart)mainPart.GetPartById(footerRef.Id.Value); + return footerPart.Footer?.Descendants().FirstOrDefault()?.RunProperties; + } + public void Dispose() { _document?.Dispose(); diff --git a/TriasDev.Templify.Tests/Integration/HeaderFooterTests.cs b/TriasDev.Templify.Tests/Integration/HeaderFooterTests.cs index ded9915..cf2ccc6 100644 --- a/TriasDev.Templify.Tests/Integration/HeaderFooterTests.cs +++ b/TriasDev.Templify.Tests/Integration/HeaderFooterTests.cs @@ -361,4 +361,129 @@ public void ProcessTemplate_EvenPageFooter_ReplacesCorrectly() 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 48a654f..46e1cbe 100644 --- a/TriasDev.Templify/Core/DocumentTemplateProcessor.cs +++ b/TriasDev.Templify/Core/DocumentTemplateProcessor.cs @@ -296,14 +296,14 @@ private static bool HasFields(WordprocessingDocument document) } // Collect field codes from body, headers, and footers - IEnumerable fieldCodes = document.MainDocumentPart.Document.Body - .Descendants(); + List fieldCodes = new List( + document.MainDocumentPart.Document.Body.Descendants()); foreach (HeaderPart headerPart in document.MainDocumentPart.HeaderParts) { if (headerPart.Header != null) { - fieldCodes = fieldCodes.Concat(headerPart.Header.Descendants()); + fieldCodes.AddRange(headerPart.Header.Descendants()); } } @@ -311,7 +311,7 @@ private static bool HasFields(WordprocessingDocument document) { if (footerPart.Footer != null) { - fieldCodes = fieldCodes.Concat(footerPart.Footer.Descendants()); + fieldCodes.AddRange(footerPart.Footer.Descendants()); } } 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/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 From 0a7a393e1852920fca4a115b8b465db5a699ceb4 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Thu, 5 Mar 2026 14:46:51 +0100 Subject: [PATCH 3/7] refactor: reduce code duplication in header/footer support - Extract GetHeaderPart/GetFooterPart helpers in DocumentVerifier - Use lazy IEnumerable in HasFields to short-circuit on first match - Extract shared GetHeaderFooterElements iterator in TemplateValidator --- .../Helpers/DocumentVerifier.cs | 146 ++++-------------- .../Core/DocumentTemplateProcessor.cs | 27 +--- TriasDev.Templify/Core/TemplateValidator.cs | 63 +++----- 3 files changed, 66 insertions(+), 170 deletions(-) diff --git a/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs b/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs index d808f7d..73286ad 100644 --- a/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs +++ b/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs @@ -584,32 +584,17 @@ public bool HasUpdateFieldsOnOpen() /// 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) { - MainDocumentPart mainPart = _document.MainDocumentPart - ?? throw new InvalidOperationException("MainDocumentPart not found"); - - Body body = mainPart.Document?.Body - ?? throw new InvalidOperationException("Document body not found"); - - SectionProperties? sectionProps = body.Elements().FirstOrDefault(); - if (sectionProps == null) - { - throw new InvalidOperationException("SectionProperties not found"); - } - - HeaderReference? headerRef = sectionProps.Elements() - .FirstOrDefault(hr => hr.Type?.Value == type); - - if (headerRef?.Id?.Value == null) - { - throw new InvalidOperationException($"Header reference of type {type} not found"); - } - - HeaderPart headerPart = (HeaderPart)mainPart.GetPartById(headerRef.Id.Value); + HeaderPart headerPart = GetHeaderPart(type); return headerPart.Header?.InnerText ?? string.Empty; } @@ -618,27 +603,7 @@ public string GetHeaderText(HeaderFooterValues type) /// public string GetFooterText(HeaderFooterValues type) { - MainDocumentPart mainPart = _document.MainDocumentPart - ?? throw new InvalidOperationException("MainDocumentPart not found"); - - Body body = mainPart.Document?.Body - ?? throw new InvalidOperationException("Document body not found"); - - SectionProperties? sectionProps = body.Elements().FirstOrDefault(); - if (sectionProps == null) - { - throw new InvalidOperationException("SectionProperties not found"); - } - - FooterReference? footerRef = sectionProps.Elements() - .FirstOrDefault(fr => fr.Type?.Value == type); - - if (footerRef?.Id?.Value == null) - { - throw new InvalidOperationException($"Footer reference of type {type} not found"); - } - - FooterPart footerPart = (FooterPart)mainPart.GetPartById(footerRef.Id.Value); + FooterPart footerPart = GetFooterPart(type); return footerPart.Footer?.InnerText ?? string.Empty; } @@ -647,27 +612,7 @@ public string GetFooterText(HeaderFooterValues type) /// public List GetHeaderParagraphTexts(HeaderFooterValues type) { - MainDocumentPart mainPart = _document.MainDocumentPart - ?? throw new InvalidOperationException("MainDocumentPart not found"); - - Body body = mainPart.Document?.Body - ?? throw new InvalidOperationException("Document body not found"); - - SectionProperties? sectionProps = body.Elements().FirstOrDefault(); - if (sectionProps == null) - { - throw new InvalidOperationException("SectionProperties not found"); - } - - HeaderReference? headerRef = sectionProps.Elements() - .FirstOrDefault(hr => hr.Type?.Value == type); - - if (headerRef?.Id?.Value == null) - { - throw new InvalidOperationException($"Header reference of type {type} not found"); - } - - HeaderPart headerPart = (HeaderPart)mainPart.GetPartById(headerRef.Id.Value); + HeaderPart headerPart = GetHeaderPart(type); return headerPart.Header?.Elements() .Select(p => p.InnerText) .ToList() ?? new List(); @@ -678,27 +623,7 @@ public List GetHeaderParagraphTexts(HeaderFooterValues type) /// public List GetFooterParagraphTexts(HeaderFooterValues type) { - MainDocumentPart mainPart = _document.MainDocumentPart - ?? throw new InvalidOperationException("MainDocumentPart not found"); - - Body body = mainPart.Document?.Body - ?? throw new InvalidOperationException("Document body not found"); - - SectionProperties? sectionProps = body.Elements().FirstOrDefault(); - if (sectionProps == null) - { - throw new InvalidOperationException("SectionProperties not found"); - } - - FooterReference? footerRef = sectionProps.Elements() - .FirstOrDefault(fr => fr.Type?.Value == type); - - if (footerRef?.Id?.Value == null) - { - throw new InvalidOperationException($"Footer reference of type {type} not found"); - } - - FooterPart footerPart = (FooterPart)mainPart.GetPartById(footerRef.Id.Value); + FooterPart footerPart = GetFooterPart(type); return footerPart.Footer?.Elements() .Select(p => p.InnerText) .ToList() ?? new List(); @@ -708,18 +633,28 @@ public List GetFooterParagraphTexts(HeaderFooterValues type) /// 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) { MainDocumentPart mainPart = _document.MainDocumentPart ?? throw new InvalidOperationException("MainDocumentPart not found"); - Body body = mainPart.Document?.Body - ?? throw new InvalidOperationException("Document body not found"); - - SectionProperties? sectionProps = body.Elements().FirstOrDefault(); - if (sectionProps == null) - { - throw new InvalidOperationException("SectionProperties not found"); - } + SectionProperties sectionProps = (mainPart.Document?.Body) + ?.Elements().FirstOrDefault() + ?? throw new InvalidOperationException("SectionProperties not found"); HeaderReference? headerRef = sectionProps.Elements() .FirstOrDefault(hr => hr.Type?.Value == type); @@ -729,31 +664,17 @@ public List GetFooterParagraphTexts(HeaderFooterValues type) throw new InvalidOperationException($"Header reference of type {type} not found"); } - HeaderPart headerPart = (HeaderPart)mainPart.GetPartById(headerRef.Id.Value); - return headerPart.Header?.Descendants().FirstOrDefault()?.RunProperties; + return (HeaderPart)mainPart.GetPartById(headerRef.Id.Value); } - /// - /// Gets the RunProperties from the first run in the default footer. - /// - public RunProperties? GetFooterRunProperties() => GetFooterRunProperties(HeaderFooterValues.Default); - - /// - /// Gets the RunProperties from the first run in the footer. - /// - public RunProperties? GetFooterRunProperties(HeaderFooterValues type) + private FooterPart GetFooterPart(HeaderFooterValues type) { MainDocumentPart mainPart = _document.MainDocumentPart ?? throw new InvalidOperationException("MainDocumentPart not found"); - Body body = mainPart.Document?.Body - ?? throw new InvalidOperationException("Document body not found"); - - SectionProperties? sectionProps = body.Elements().FirstOrDefault(); - if (sectionProps == null) - { - throw new InvalidOperationException("SectionProperties not found"); - } + SectionProperties sectionProps = (mainPart.Document?.Body) + ?.Elements().FirstOrDefault() + ?? throw new InvalidOperationException("SectionProperties not found"); FooterReference? footerRef = sectionProps.Elements() .FirstOrDefault(fr => fr.Type?.Value == type); @@ -763,8 +684,7 @@ public List GetFooterParagraphTexts(HeaderFooterValues type) throw new InvalidOperationException($"Footer reference of type {type} not found"); } - FooterPart footerPart = (FooterPart)mainPart.GetPartById(footerRef.Id.Value); - return footerPart.Footer?.Descendants().FirstOrDefault()?.RunProperties; + return (FooterPart)mainPart.GetPartById(footerRef.Id.Value); } public void Dispose() diff --git a/TriasDev.Templify/Core/DocumentTemplateProcessor.cs b/TriasDev.Templify/Core/DocumentTemplateProcessor.cs index 46e1cbe..6b67d04 100644 --- a/TriasDev.Templify/Core/DocumentTemplateProcessor.cs +++ b/TriasDev.Templify/Core/DocumentTemplateProcessor.cs @@ -295,25 +295,14 @@ private static bool HasFields(WordprocessingDocument document) return false; } - // Collect field codes from body, headers, and footers - List fieldCodes = new List( - document.MainDocumentPart.Document.Body.Descendants()); - - foreach (HeaderPart headerPart in document.MainDocumentPart.HeaderParts) - { - if (headerPart.Header != null) - { - fieldCodes.AddRange(headerPart.Header.Descendants()); - } - } - - foreach (FooterPart footerPart in document.MainDocumentPart.FooterParts) - { - if (footerPart.Footer != null) - { - fieldCodes.AddRange(footerPart.Footer.Descendants()); - } - } + // 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 => { diff --git a/TriasDev.Templify/Core/TemplateValidator.cs b/TriasDev.Templify/Core/TemplateValidator.cs index e16d663..f029e77 100644 --- a/TriasDev.Templify/Core/TemplateValidator.cs +++ b/TriasDev.Templify/Core/TemplateValidator.cs @@ -586,26 +586,20 @@ private static bool CanResolveInScope( } /// - /// Validates template elements in all headers and footers. + /// Gets all element lists from header and footer parts in the document. /// - private static void ValidateHeadersAndFooters( - WordprocessingDocument document, - HashSet allPlaceholders, - List errors) + private static IEnumerable> GetHeaderFooterElements(WordprocessingDocument document) { if (document.MainDocumentPart == null) { - return; + yield break; } foreach (HeaderPart headerPart in document.MainDocumentPart.HeaderParts) { if (headerPart.Header != null) { - List headerElements = headerPart.Header.Elements().ToList(); - _ = ValidateConditionals(headerElements, allPlaceholders, errors); - ValidateLoops(headerElements, allPlaceholders, errors); - FindAllPlaceholdersInElements(headerElements, allPlaceholders); + yield return headerPart.Header.Elements().ToList(); } } @@ -613,14 +607,27 @@ private static void ValidateHeadersAndFooters( { if (footerPart.Footer != null) { - List footerElements = footerPart.Footer.Elements().ToList(); - _ = ValidateConditionals(footerElements, allPlaceholders, errors); - ValidateLoops(footerElements, allPlaceholders, errors); - FindAllPlaceholdersInElements(footerElements, allPlaceholders); + 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); + FindAllPlaceholdersInElements(elements, allPlaceholders); + } + } + /// /// Validates missing variables in headers and footers. /// @@ -633,33 +640,13 @@ private static void ValidateHeaderFooterMissingVariables( List errors, bool warnOnEmptyLoopCollections) { - if (document.MainDocumentPart == null) - { - return; - } - ValueResolver resolver = new ValueResolver(); - foreach (HeaderPart headerPart in document.MainDocumentPart.HeaderParts) + foreach (List elements in GetHeaderFooterElements(document)) { - if (headerPart.Header != null) - { - List elements = headerPart.Header.Elements().ToList(); - 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); - } - } - - foreach (FooterPart footerPart in document.MainDocumentPart.FooterParts) - { - if (footerPart.Footer != null) - { - List elements = footerPart.Footer.Elements().ToList(); - 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); - } + 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); } } From d5a086e8809b3813e132b26267e25e2d007408e8 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Thu, 5 Mar 2026 14:51:41 +0100 Subject: [PATCH 4/7] refactor: extract shared helpers to reduce header/footer code duplication - DocumentBuilder: extract AddHeaderPart/AddFooterPart/AppendParagraphs helpers - DocumentVerifier: extract generic GetHeaderFooterPart helper - TemplateValidator: delegate FindAllPlaceholders to FindAllPlaceholdersInElements --- .../Helpers/DocumentBuilder.cs | 152 +++++------------- .../Helpers/DocumentVerifier.cs | 33 ++-- TriasDev.Templify/Core/TemplateValidator.cs | 9 +- 3 files changed, 57 insertions(+), 137 deletions(-) diff --git a/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs b/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs index fe28d64..43c06ad 100644 --- a/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs +++ b/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs @@ -546,23 +546,7 @@ public DocumentBuilder SetSubject(string subject) /// public DocumentBuilder AddHeader(string text, HeaderFooterValues type) { - MainDocumentPart mainPart = _document.MainDocumentPart!; - - HeaderPart headerPart = mainPart.AddNewPart(); - Header header = new Header(); - Paragraph paragraph = new Paragraph(); - Run run = new Run(); - Text textElement = new Text(text) { Space = SpaceProcessingModeValues.Preserve }; - run.Append(textElement); - paragraph.Append(run); - header.Append(paragraph); - headerPart.Header = header; - headerPart.Header.Save(); - - string headerPartId = mainPart.GetIdOfPart(headerPart); - EnsureSectionProperties().Append(new HeaderReference { Type = type, Id = headerPartId }); - - return this; + return AddHeaderPart(type, null, text); } /// @@ -570,28 +554,7 @@ public DocumentBuilder AddHeader(string text, HeaderFooterValues type) /// public DocumentBuilder AddHeaderWithParagraphs(HeaderFooterValues type, params string[] texts) { - MainDocumentPart mainPart = _document.MainDocumentPart!; - - HeaderPart headerPart = mainPart.AddNewPart(); - Header header = new Header(); - - foreach (string text in texts) - { - Paragraph paragraph = new Paragraph(); - Run run = new Run(); - Text textElement = new Text(text) { Space = SpaceProcessingModeValues.Preserve }; - run.Append(textElement); - paragraph.Append(run); - header.Append(paragraph); - } - - headerPart.Header = header; - headerPart.Header.Save(); - - string headerPartId = mainPart.GetIdOfPart(headerPart); - EnsureSectionProperties().Append(new HeaderReference { Type = type, Id = headerPartId }); - - return this; + return AddHeaderPart(type, null, texts); } /// @@ -604,23 +567,7 @@ public DocumentBuilder AddHeaderWithParagraphs(HeaderFooterValues type, params s /// public DocumentBuilder AddFooter(string text, HeaderFooterValues type) { - MainDocumentPart mainPart = _document.MainDocumentPart!; - - FooterPart footerPart = mainPart.AddNewPart(); - Footer footer = new Footer(); - Paragraph paragraph = new Paragraph(); - Run run = new Run(); - Text textElement = new Text(text) { Space = SpaceProcessingModeValues.Preserve }; - run.Append(textElement); - paragraph.Append(run); - footer.Append(paragraph); - footerPart.Footer = footer; - footerPart.Footer.Save(); - - string footerPartId = mainPart.GetIdOfPart(footerPart); - EnsureSectionProperties().Append(new FooterReference { Type = type, Id = footerPartId }); - - return this; + return AddFooterPart(type, null, text); } /// @@ -628,28 +575,7 @@ public DocumentBuilder AddFooter(string text, HeaderFooterValues type) /// public DocumentBuilder AddFooterWithParagraphs(HeaderFooterValues type, params string[] texts) { - MainDocumentPart mainPart = _document.MainDocumentPart!; - - FooterPart footerPart = mainPart.AddNewPart(); - Footer footer = new Footer(); - - foreach (string text in texts) - { - Paragraph paragraph = new Paragraph(); - Run run = new Run(); - Text textElement = new Text(text) { Space = SpaceProcessingModeValues.Preserve }; - run.Append(textElement); - paragraph.Append(run); - footer.Append(paragraph); - } - - footerPart.Footer = footer; - footerPart.Footer.Save(); - - string footerPartId = mainPart.GetIdOfPart(footerPart); - EnsureSectionProperties().Append(new FooterReference { Type = type, Id = footerPartId }); - - return this; + return AddFooterPart(type, null, texts); } /// @@ -662,24 +588,7 @@ public DocumentBuilder AddFooterWithParagraphs(HeaderFooterValues type, params s /// public DocumentBuilder AddHeader(string text, RunProperties formatting, HeaderFooterValues type) { - MainDocumentPart mainPart = _document.MainDocumentPart!; - - HeaderPart headerPart = mainPart.AddNewPart(); - Header header = new Header(); - Paragraph paragraph = new Paragraph(); - Run run = new Run(); - Text textElement = new Text(text) { Space = SpaceProcessingModeValues.Preserve }; - run.Append(textElement); - run.RunProperties = (RunProperties)formatting.CloneNode(true); - paragraph.Append(run); - header.Append(paragraph); - headerPart.Header = header; - headerPart.Header.Save(); - - string headerPartId = mainPart.GetIdOfPart(headerPart); - EnsureSectionProperties().Append(new HeaderReference { Type = type, Id = headerPartId }); - - return this; + return AddHeaderPart(type, formatting, text); } /// @@ -691,27 +600,54 @@ public DocumentBuilder AddHeader(string text, RunProperties formatting, HeaderFo /// 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); + EnsureSectionProperties().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(); - Footer footer = new Footer(); - Paragraph paragraph = new Paragraph(); - Run run = new Run(); - Text textElement = new Text(text) { Space = SpaceProcessingModeValues.Preserve }; - run.Append(textElement); - run.RunProperties = (RunProperties)formatting.CloneNode(true); - paragraph.Append(run); - footer.Append(paragraph); - footerPart.Footer = footer; + footerPart.Footer = new Footer(); + AppendParagraphs(footerPart.Footer, formatting, texts); footerPart.Footer.Save(); - string footerPartId = mainPart.GetIdOfPart(footerPart); - EnsureSectionProperties().Append(new FooterReference { Type = type, Id = footerPartId }); - + string partId = mainPart.GetIdOfPart(footerPart); + EnsureSectionProperties().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. /// diff --git a/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs b/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs index 73286ad..d6c4a7a 100644 --- a/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs +++ b/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs @@ -649,25 +649,16 @@ public List GetFooterParagraphTexts(HeaderFooterValues type) private HeaderPart GetHeaderPart(HeaderFooterValues type) { - MainDocumentPart mainPart = _document.MainDocumentPart - ?? throw new InvalidOperationException("MainDocumentPart not found"); - - SectionProperties sectionProps = (mainPart.Document?.Body) - ?.Elements().FirstOrDefault() - ?? throw new InvalidOperationException("SectionProperties not found"); - - HeaderReference? headerRef = sectionProps.Elements() - .FirstOrDefault(hr => hr.Type?.Value == type); - - if (headerRef?.Id?.Value == null) - { - throw new InvalidOperationException($"Header reference of type {type} not found"); - } - - return (HeaderPart)mainPart.GetPartById(headerRef.Id.Value); + 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"); @@ -676,15 +667,15 @@ private FooterPart GetFooterPart(HeaderFooterValues type) ?.Elements().FirstOrDefault() ?? throw new InvalidOperationException("SectionProperties not found"); - FooterReference? footerRef = sectionProps.Elements() - .FirstOrDefault(fr => fr.Type?.Value == type); + TReference? reference = sectionProps.Elements() + .FirstOrDefault(r => r.Type?.Value == type); - if (footerRef?.Id?.Value == null) + if (reference?.Id?.Value == null) { - throw new InvalidOperationException($"Footer reference of type {type} not found"); + throw new InvalidOperationException($"{partName} reference of type {type} not found"); } - return (FooterPart)mainPart.GetPartById(footerRef.Id.Value); + return mainPart.GetPartById(reference.Id.Value); } public void Dispose() diff --git a/TriasDev.Templify/Core/TemplateValidator.cs b/TriasDev.Templify/Core/TemplateValidator.cs index f029e77..47dd82e 100644 --- a/TriasDev.Templify/Core/TemplateValidator.cs +++ b/TriasDev.Templify/Core/TemplateValidator.cs @@ -199,14 +199,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); } /// From d828cd85281e2a5707842c7b1d49407066080b8c Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Thu, 5 Mar 2026 15:08:22 +0100 Subject: [PATCH 5/7] fix: address PR review comments for header/footer support - Move SectionProperties to end of Body in DocumentBuilder.ToStream() - Remove duplicate header/footer references of same type before adding - Handle null Type as Default in DocumentVerifier reference lookup - Add table-row loop validation for headers/footers in TemplateValidator --- .../Helpers/DocumentBuilder.cs | 26 +++++++++++++++-- .../Helpers/DocumentVerifier.cs | 2 +- TriasDev.Templify/Core/TemplateValidator.cs | 28 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs b/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs index 43c06ad..8854325 100644 --- a/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs +++ b/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs @@ -613,7 +613,14 @@ private DocumentBuilder AddHeaderPart(HeaderFooterValues type, RunProperties? fo headerPart.Header.Save(); string partId = mainPart.GetIdOfPart(headerPart); - EnsureSectionProperties().Append(new HeaderReference { Type = type, Id = partId }); + SectionProperties sectionProps = EnsureSectionProperties(); + + // Remove existing header reference of the same type to avoid duplicates + HeaderReference? existing = sectionProps.Elements() + .FirstOrDefault(r => r.Type?.Value == type); + existing?.Remove(); + + sectionProps.Append(new HeaderReference { Type = type, Id = partId }); return this; } @@ -626,7 +633,14 @@ private DocumentBuilder AddFooterPart(HeaderFooterValues type, RunProperties? fo footerPart.Footer.Save(); string partId = mainPart.GetIdOfPart(footerPart); - EnsureSectionProperties().Append(new FooterReference { Type = type, Id = partId }); + SectionProperties sectionProps = EnsureSectionProperties(); + + // Remove existing footer reference of the same type to avoid duplicates + FooterReference? existingFooter = sectionProps.Elements() + .FirstOrDefault(r => r.Type?.Value == type); + existingFooter?.Remove(); + + sectionProps.Append(new FooterReference { Type = type, Id = partId }); return this; } @@ -668,6 +682,14 @@ private SectionProperties EnsureSectionProperties() /// 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 d6c4a7a..44ead26 100644 --- a/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs +++ b/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs @@ -668,7 +668,7 @@ private OpenXmlPart GetHeaderFooterPart(HeaderFooterValues type, str ?? throw new InvalidOperationException("SectionProperties not found"); TReference? reference = sectionProps.Elements() - .FirstOrDefault(r => r.Type?.Value == type); + .FirstOrDefault(r => r.Type?.Value == type || (type == HeaderFooterValues.Default && r.Type == null)); if (reference?.Id?.Value == null) { diff --git a/TriasDev.Templify/Core/TemplateValidator.cs b/TriasDev.Templify/Core/TemplateValidator.cs index 47dd82e..1a5d223 100644 --- a/TriasDev.Templify/Core/TemplateValidator.cs +++ b/TriasDev.Templify/Core/TemplateValidator.cs @@ -194,6 +194,33 @@ private static void ValidateTableRowLoops( } } + /// + /// 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 + { + IReadOnlyList tableLoops = LoopDetector.DetectTableRowLoops(table); + foreach (LoopBlock block in tableLoops) + { + allPlaceholders.Add(block.CollectionName); + } + } + catch (InvalidOperationException ex) + { + errors.Add(ValidationError.Create( + ValidationErrorType.UnmatchedLoopStart, + ex.Message)); + } + } + } + /// /// Finds all regular placeholders in the document body. /// @@ -617,6 +644,7 @@ private static void ValidateHeadersAndFooters( { _ = ValidateConditionals(elements, allPlaceholders, errors); ValidateLoops(elements, allPlaceholders, errors); + ValidateTableRowLoopsInElements(elements, allPlaceholders, errors); FindAllPlaceholdersInElements(elements, allPlaceholders); } } From b6610533f1a3c26089b04e2f9e308f580b017ed3 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Thu, 5 Mar 2026 15:54:05 +0100 Subject: [PATCH 6/7] refactor: delegate ValidateTableRowLoops to ValidateTableRowLoopsInElements to remove duplication --- TriasDev.Templify/Core/TemplateValidator.cs | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/TriasDev.Templify/Core/TemplateValidator.cs b/TriasDev.Templify/Core/TemplateValidator.cs index 1a5d223..a8a44b4 100644 --- a/TriasDev.Templify/Core/TemplateValidator.cs +++ b/TriasDev.Templify/Core/TemplateValidator.cs @@ -175,23 +175,7 @@ private static void ValidateTableRowLoops( HashSet allPlaceholders, List errors) { - foreach (Table table in body.Elements
()) - { - try - { - IReadOnlyList tableLoops = LoopDetector.DetectTableRowLoops(table); - foreach (LoopBlock block in tableLoops) - { - allPlaceholders.Add(block.CollectionName); - } - } - catch (InvalidOperationException ex) - { - errors.Add(ValidationError.Create( - ValidationErrorType.UnmatchedLoopStart, - ex.Message)); - } - } + ValidateTableRowLoopsInElements(body.Elements().ToList(), allPlaceholders, errors); } /// From 7735a0c90393c4ef4954180f78c28af5e635d678 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Thu, 5 Mar 2026 16:05:09 +0100 Subject: [PATCH 7/7] fix: handle null-Type header/footer references in DocumentBuilder AddHeaderPart/AddFooterPart now treat a missing Type attribute as Default when checking for duplicate references, matching the behavior already present in DocumentVerifier.GetHeaderFooterPart. --- TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs b/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs index 8854325..9a9c1a0 100644 --- a/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs +++ b/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs @@ -617,7 +617,7 @@ private DocumentBuilder AddHeaderPart(HeaderFooterValues type, RunProperties? fo // Remove existing header reference of the same type to avoid duplicates HeaderReference? existing = sectionProps.Elements() - .FirstOrDefault(r => r.Type?.Value == type); + .FirstOrDefault(r => r.Type?.Value == type || (type == HeaderFooterValues.Default && r.Type == null)); existing?.Remove(); sectionProps.Append(new HeaderReference { Type = type, Id = partId }); @@ -637,7 +637,7 @@ private DocumentBuilder AddFooterPart(HeaderFooterValues type, RunProperties? fo // Remove existing footer reference of the same type to avoid duplicates FooterReference? existingFooter = sectionProps.Elements() - .FirstOrDefault(r => r.Type?.Value == type); + .FirstOrDefault(r => r.Type?.Value == type || (type == HeaderFooterValues.Default && r.Type == null)); existingFooter?.Remove(); sectionProps.Append(new FooterReference { Type = type, Id = partId });