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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
149 changes: 149 additions & 0 deletions TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -536,11 +536,160 @@ public DocumentBuilder SetSubject(string subject)
return this;
}

/// <summary>
/// Adds a default header with the specified text.
/// </summary>
public DocumentBuilder AddHeader(string text) => AddHeader(text, HeaderFooterValues.Default);

/// <summary>
/// Adds a header with the specified text.
/// </summary>
public DocumentBuilder AddHeader(string text, HeaderFooterValues type)
{
return AddHeaderPart(type, null, text);
}

/// <summary>
/// Adds a header with multiple paragraphs.
/// </summary>
public DocumentBuilder AddHeaderWithParagraphs(HeaderFooterValues type, params string[] texts)
{
return AddHeaderPart(type, null, texts);
}

/// <summary>
/// Adds a default footer with the specified text.
/// </summary>
public DocumentBuilder AddFooter(string text) => AddFooter(text, HeaderFooterValues.Default);

/// <summary>
/// Adds a footer with the specified text.
/// </summary>
public DocumentBuilder AddFooter(string text, HeaderFooterValues type)
{
return AddFooterPart(type, null, text);
}

/// <summary>
/// Adds a footer with multiple paragraphs.
/// </summary>
public DocumentBuilder AddFooterWithParagraphs(HeaderFooterValues type, params string[] texts)
{
return AddFooterPart(type, null, texts);
}

/// <summary>
/// Adds a default header with the specified text and formatting.
/// </summary>
public DocumentBuilder AddHeader(string text, RunProperties formatting) => AddHeader(text, formatting, HeaderFooterValues.Default);

/// <summary>
/// Adds a header with the specified text and formatting.
/// </summary>
public DocumentBuilder AddHeader(string text, RunProperties formatting, HeaderFooterValues type)
{
return AddHeaderPart(type, formatting, text);
}

/// <summary>
/// Adds a default footer with the specified text and formatting.
/// </summary>
public DocumentBuilder AddFooter(string text, RunProperties formatting) => AddFooter(text, formatting, HeaderFooterValues.Default);

/// <summary>
/// Adds a footer with the specified text and formatting.
/// </summary>
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>();
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<HeaderReference>()
.FirstOrDefault(r => r.Type?.Value == type || (type == HeaderFooterValues.Default && r.Type == null));
existing?.Remove();

sectionProps.Append(new HeaderReference { Type = type, Id = partId });
return this;
Comment on lines +618 to +624
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AddHeaderPart intends to avoid duplicate header references, but it only removes the first match and doesn’t account for existing default header refs where Type is omitted (Word treats missing Type as Default). This can leave multiple default header references in SectionProperties, which can confuse Word and tests that expect a single effective header. Consider removing all matching references, and treat (type == Default && r.Type == null) as a match as well.

Copilot uses AI. Check for mistakes.
}

private DocumentBuilder AddFooterPart(HeaderFooterValues type, RunProperties? formatting, params string[] texts)
{
MainDocumentPart mainPart = _document.MainDocumentPart!;
FooterPart footerPart = mainPart.AddNewPart<FooterPart>();
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<FooterReference>()
.FirstOrDefault(r => r.Type?.Value == type || (type == HeaderFooterValues.Default && r.Type == null));
existingFooter?.Remove();

sectionProps.Append(new FooterReference { Type = type, Id = partId });
return this;
Comment on lines +607 to +644
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AddHeaderPart/AddFooterPart always Append a new HeaderReference/FooterReference for the given type. If a caller adds the same header/footer type twice, the document ends up with multiple references of the same type, and DocumentVerifier currently reads the first one. Consider removing/replacing any existing reference of the same Type before appending the new one.

Copilot uses AI. Check for mistakes.
Comment on lines +638 to +644
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue for footers: AddFooterPart only removes the first matching FooterReference and won’t remove an existing default footer ref with Type omitted. This can result in duplicate footer references for the default case. Remove all matches and include the (type == Default && r.Type == null) case.

Copilot uses AI. Check for mistakes.
}

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

/// <summary>
/// Ensures the body has SectionProperties and returns it.
/// </summary>
private SectionProperties EnsureSectionProperties()
{
SectionProperties? sectionProps = _body.Elements<SectionProperties>().FirstOrDefault();
if (sectionProps == null)
{
sectionProps = new SectionProperties();
_body.Append(sectionProps);
}

return sectionProps;
}
Comment on lines +607 to +678
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AddHeaderPart/AddFooterPart append SectionProperties to the body when it doesn’t exist. Since AddParagraph (and other content builders) always use _body.Append(...), any content added after calling AddHeader/AddFooter will be appended after SectionProperties, producing an invalid WordprocessingML body order (sectPr must be last). Consider inserting new paragraphs/tables before an existing SectionProperties, or moving SectionProperties to the end before saving.

Copilot uses AI. Check for mistakes.

/// <summary>
/// Returns the document as a MemoryStream for processing.
/// </summary>
public MemoryStream ToStream()
{
// Ensure SectionProperties is the last child of Body (OpenXML schema requirement)
SectionProperties? sectPr = _body.Elements<SectionProperties>().FirstOrDefault();
if (sectPr != null)
{
sectPr.Remove();
_body.Append(sectPr);
}

// Save and close the document
_document.MainDocumentPart!.Document.Save();
_document.Dispose();
Expand Down
109 changes: 109 additions & 0 deletions TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,115 @@ public bool HasUpdateFieldsOnOpen()
/// </summary>
public string? GetDocumentLastModifiedBy() => _document.PackageProperties.LastModifiedBy;

/// <summary>
/// Gets the text content of the default header.
/// </summary>
public string GetHeaderText() => GetHeaderText(HeaderFooterValues.Default);

/// <summary>
/// Gets the text content of the default footer.
/// </summary>
public string GetFooterText() => GetFooterText(HeaderFooterValues.Default);

/// <summary>
/// Gets the RunProperties from the first run in the default header.
/// </summary>
public RunProperties? GetHeaderRunProperties() => GetHeaderRunProperties(HeaderFooterValues.Default);

/// <summary>
/// Gets the RunProperties from the first run in the default footer.
/// </summary>
public RunProperties? GetFooterRunProperties() => GetFooterRunProperties(HeaderFooterValues.Default);

/// <summary>
/// Gets the text content of a header by type.
/// </summary>
public string GetHeaderText(HeaderFooterValues type)
{
HeaderPart headerPart = GetHeaderPart(type);
return headerPart.Header?.InnerText ?? string.Empty;
}

/// <summary>
/// Gets the text content of a footer by type.
/// </summary>
public string GetFooterText(HeaderFooterValues type)
{
FooterPart footerPart = GetFooterPart(type);
return footerPart.Footer?.InnerText ?? string.Empty;
}

/// <summary>
/// Gets all paragraph texts from a header by type.
/// </summary>
public List<string> GetHeaderParagraphTexts(HeaderFooterValues type)
{
HeaderPart headerPart = GetHeaderPart(type);
return headerPart.Header?.Elements<Paragraph>()
.Select(p => p.InnerText)
.ToList() ?? new List<string>();
}

/// <summary>
/// Gets all paragraph texts from a footer by type.
/// </summary>
public List<string> GetFooterParagraphTexts(HeaderFooterValues type)
{
FooterPart footerPart = GetFooterPart(type);
return footerPart.Footer?.Elements<Paragraph>()
.Select(p => p.InnerText)
.ToList() ?? new List<string>();
}

/// <summary>
/// Gets the RunProperties from the first run in the header.
/// </summary>
public RunProperties? GetHeaderRunProperties(HeaderFooterValues type)
{
HeaderPart headerPart = GetHeaderPart(type);
return headerPart.Header?.Descendants<Run>().FirstOrDefault()?.RunProperties;
}

/// <summary>
/// Gets the RunProperties from the first run in the footer.
/// </summary>
public RunProperties? GetFooterRunProperties(HeaderFooterValues type)
{
FooterPart footerPart = GetFooterPart(type);
return footerPart.Footer?.Descendants<Run>().FirstOrDefault()?.RunProperties;
}

private HeaderPart GetHeaderPart(HeaderFooterValues type)
{
return (HeaderPart)GetHeaderFooterPart<HeaderReference>(type, "Header");
}

private FooterPart GetFooterPart(HeaderFooterValues type)
{
return (FooterPart)GetHeaderFooterPart<FooterReference>(type, "Footer");
}

private OpenXmlPart GetHeaderFooterPart<TReference>(HeaderFooterValues type, string partName)
where TReference : HeaderFooterReferenceType
{
MainDocumentPart mainPart = _document.MainDocumentPart
?? throw new InvalidOperationException("MainDocumentPart not found");

SectionProperties sectionProps = (mainPart.Document?.Body)
?.Elements<SectionProperties>().FirstOrDefault()
?? throw new InvalidOperationException("SectionProperties not found");

TReference? reference = sectionProps.Elements<TReference>()
.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");
}
Comment on lines +670 to +676
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetHeaderFooterPart only matches references where r.Type?.Value == type. In real Word docs, the default header/footer reference often omits the w:type attribute (null implies Default). As written, GetHeaderText()/GetFooterText() can throw for valid documents. Consider treating Type == null as HeaderFooterValues.Default when type is Default (and/or falling back to a reference with null type).

Copilot uses AI. Check for mistakes.

return mainPart.GetPartById(reference.Id.Value);
}

public void Dispose()
{
_document?.Dispose();
Expand Down
Loading
Loading