Skip to content

Commit 7e413e9

Browse files
committed
Support header and footer in DOCX renderer
1 parent 6e7f9f4 commit 7e413e9

15 files changed

+472
-146
lines changed

src/DocSharp.Docx/DocxEnumerator.cs

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -953,7 +953,7 @@ internal void ProcessLastFooter(SectionProperties properties, TOutput sb)
953953

954954
internal SectionProperties? FindPreviousSectionProperties(SectionProperties sectionProperties)
955955
{
956-
var section = Sections.Where(s => s.properties == sectionProperties).FirstOrDefault();
956+
var section = Sections.FirstOrDefault(s => s.properties == sectionProperties);
957957
int i = Sections.IndexOf(section);
958958
if (i == 0)
959959
{
@@ -970,8 +970,8 @@ internal void ProcessLastFooter(SectionProperties properties, TOutput sb)
970970
return null;
971971
}
972972
if (sectionProperties.Elements<HeaderReference>()
973-
.Where(hr => (hr.Type != null && hr.Type == type) || (hr.Type == null && type == HeaderFooterValues.Default))
974-
.FirstOrDefault() is HeaderReference headerRef)
973+
.FirstOrDefault(hr => (hr.Type != null && hr.Type == type) || (hr.Type == null && type == HeaderFooterValues.Default))
974+
is HeaderReference headerRef)
975975
{
976976
return headerRef;
977977
}
@@ -985,8 +985,8 @@ internal void ProcessLastFooter(SectionProperties properties, TOutput sb)
985985
return null;
986986
}
987987
if (sectionProperties.Elements<FooterReference>()
988-
.Where(hr => (hr.Type != null && hr.Type == type) || (hr.Type == null && type == HeaderFooterValues.Default))
989-
.FirstOrDefault() is FooterReference footerRef)
988+
.FirstOrDefault(hr => (hr.Type != null && hr.Type == type) || (hr.Type == null && type == HeaderFooterValues.Default))
989+
is FooterReference footerRef)
990990
{
991991
return footerRef;
992992
}
@@ -995,30 +995,20 @@ internal void ProcessLastFooter(SectionProperties properties, TOutput sb)
995995

996996
internal virtual void ProcessHeaderReference(HeaderReference? headerRef, TOutput writer)
997997
{
998-
if (headerRef != null)
998+
if (headerRef != null &&
999+
HeaderFooterHelpers.GetHeaderFromReference(headerRef, headerRef.GetMainDocumentPart()) is Header header)
9991000
{
1000-
var mainPart = headerRef.GetMainDocumentPart();
1001-
if (mainPart != null &&
1002-
headerRef?.Id?.Value is string headerId &&
1003-
mainPart.GetPartById(headerId) is HeaderPart headerPart)
1004-
{
1005-
ProcessHeader(headerPart.Header, writer);
1006-
}
1001+
ProcessHeader(header, writer);
10071002
}
10081003
}
10091004

10101005
internal virtual void ProcessFooterReference(FooterReference? footerRef, TOutput writer)
10111006
{
1012-
if (footerRef != null)
1007+
if (footerRef != null &&
1008+
HeaderFooterHelpers.GetFooterFromReference(footerRef, footerRef.GetMainDocumentPart()) is Footer footer)
10131009
{
1014-
var mainPart = footerRef.GetMainDocumentPart();
1015-
if (mainPart != null &&
1016-
footerRef?.Id?.Value is string headerId &&
1017-
mainPart.GetPartById(headerId) is FooterPart footerPart)
1018-
{
1019-
ProcessFooter(footerPart.Footer, writer);
1020-
}
1021-
}
1010+
ProcessFooter(footer, writer);
1011+
}
10221012
}
10231013

10241014
internal virtual void ProcessHeader(Header header, TOutput writer)

src/DocSharp.Docx/DocxToMarkdownConverter.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,7 @@ internal override void ProcessMathElement(OpenXmlElement element, MarkdownString
658658
// To preserve formatting such as bold or color we would need to convert these to LaTex syntax,
659659
// as regular Markdown can't be added to LaTex blocks.
660660
// - OfficeMath and Math.Paragraph elements nested into another OfficeMath element are not supported.
661-
//
661+
// (rare, I have never seen this in a real DOCX document).
662662
string latex;
663663
try
664664
{
@@ -681,7 +681,7 @@ internal override void ProcessMathElement(OpenXmlElement element, MarkdownString
681681
// Process word processing element (hyperlink, bookmark, ...)
682682
ProcessParagraphElement(element.LastChild, sb);
683683
}
684-
if (element.LastChild is M.Run run && run.LastChild != null && !run.LastChild.IsMathElement())
684+
else if (element.LastChild is M.Run run && run.LastChild != null && !run.LastChild.IsMathElement())
685685
{
686686
// Process word processing element (break, regular text, ...)
687687
ProcessRunElement(run.LastChild, sb);
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using DocumentFormat.OpenXml.Packaging;
2+
using DocumentFormat.OpenXml.Wordprocessing;
3+
4+
namespace DocSharp.Docx;
5+
6+
public static class HeaderFooterHelpers
7+
{
8+
public static Header? GetHeaderFromReference(HeaderReference? headerReference, MainDocumentPart? mainPart)
9+
{
10+
if (headerReference != null && mainPart != null && headerReference?.Id?.Value is string headerId)
11+
return (mainPart.GetPartById(headerId) as HeaderPart)?.Header;
12+
else
13+
return null;
14+
}
15+
16+
public static Footer? GetFooterFromReference(FooterReference? footerReference, MainDocumentPart? mainPart)
17+
{
18+
if (footerReference != null && mainPart != null && footerReference?.Id?.Value is string footerId)
19+
return (mainPart.GetPartById(footerId) as FooterPart)?.Footer;
20+
else
21+
return null;
22+
}
23+
}

src/WIP/DocSharp.Renderer/DocSharp.Renderer.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
<ItemGroup>
2121
<PackageReference Include="QuestPDF" Version="2025.12.0" />
22+
<PackageReference Include="CSharpMath.Rendering" Version="0.5.1" />
23+
<!-- <PackageReference Include="CSharpMath.SkiaSharp" Version="0.5.1" /> -->
2224
</ItemGroup>
2325

2426
<ItemGroup>
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Xml.Linq;
6+
using DocSharp.Docx;
7+
using DocumentFormat.OpenXml;
8+
using DocumentFormat.OpenXml.Packaging;
9+
using DocumentFormat.OpenXml.Wordprocessing;
10+
using W = DocumentFormat.OpenXml.Wordprocessing;
11+
using QuestPDF.Fluent;
12+
using System.Globalization;
13+
using M = DocumentFormat.OpenXml.Math;
14+
using System.Diagnostics;
15+
16+
namespace DocSharp.Renderer;
17+
18+
public partial class DocxRenderer : DocxEnumerator<QuestPdfModel>, IDocumentRenderer<QuestPDF.Fluent.Document>
19+
{
20+
internal void ProcessHeaderFooters(SectionProperties sectionProperties, QuestPdfPageSet pageSet, MainDocumentPart mainPart, QuestPdfModel output)
21+
{
22+
// Rules for header and footer in DOCX:
23+
// https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.headerreference?view=openxml-3.0.1
24+
// https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.footerreference?view=openxml-3.0.1
25+
// https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.evenandoddHeaders?view=openxml-3.0.1
26+
// https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.titlepage?view=openxml-3.0.1
27+
28+
// Check if different headers/footers for odd/even pages are enabled in this document.
29+
var documentSettings = mainPart.DocumentSettingsPart?.Settings;
30+
bool evenOddEnabled = (documentSettings?.GetFirstChild<EvenAndOddHeaders>()).ToBool();
31+
// In DOCX, this setting affects both headers and footers, and all sections in the document.
32+
33+
// Check if different header/footer for the first page in the section are enabled
34+
bool firstPageEnabled = sectionProperties.GetFirstChild<TitlePage>().ToBool();
35+
// In DOCX, this setting affects both headers and footers but is section-specific.
36+
37+
// EvenAndOddHeaders and TitlePage are assumed false if not present.
38+
39+
40+
// Process default header for this section.
41+
var headerRefDefault = FindHeaderReference(sectionProperties, HeaderFooterValues.Default);
42+
// Header and footer of a specified type (default/odd/first) should be inherited
43+
// from the previous section, if not defined in this section.
44+
// The FindHeaderReference and FindFooterReference functions handle this logic.
45+
if (HeaderFooterHelpers.GetHeaderFromReference(headerRefDefault, mainPart) is Header defaultHeader)
46+
{
47+
currentContainer.Push(pageSet.HeaderOddOrDefault);
48+
base.ProcessHeader(defaultHeader, output);
49+
if (currentContainer.Count > 0)
50+
currentContainer.Pop();
51+
}
52+
53+
// Process default footer for this section
54+
var footerRefDefault = FindFooterReference(sectionProperties, HeaderFooterValues.Default);
55+
if (HeaderFooterHelpers.GetFooterFromReference(footerRefDefault, mainPart) is Footer defaultFooter)
56+
{
57+
currentContainer.Push(pageSet.FooterOddOrDefault);
58+
base.ProcessFooter(defaultFooter, output);
59+
if (currentContainer.Count > 0)
60+
currentContainer.Pop();
61+
}
62+
63+
// Process even header and footer, if enabled.
64+
// If not enabled, header/footer for even pages should be ignored if present.
65+
if (evenOddEnabled)
66+
{
67+
// If no header/footer for even pages is found in this section or the previous ones,
68+
// a blank header/footer is created.
69+
// Another header/footer type should *not* be used in its place.
70+
pageSet.HeaderEven = new();
71+
pageSet.FooterEven = new();
72+
73+
// Try to find even pages header
74+
var headerRefEven = FindHeaderReference(sectionProperties, HeaderFooterValues.Even);
75+
if (HeaderFooterHelpers.GetHeaderFromReference(headerRefEven, mainPart) is Header evenHeader)
76+
{
77+
// Process even pages header
78+
currentContainer.Push(pageSet.HeaderEven);
79+
base.ProcessHeader(evenHeader, output);
80+
if (currentContainer.Count > 0)
81+
currentContainer.Pop();
82+
}
83+
84+
// Try to find even pages footer
85+
var footerRefEven = FindFooterReference(sectionProperties, HeaderFooterValues.Even);
86+
if (HeaderFooterHelpers.GetFooterFromReference(footerRefEven, mainPart) is Footer evenFooter)
87+
{
88+
// Process even pages footer
89+
currentContainer.Push(pageSet.FooterEven);
90+
base.ProcessFooter(evenFooter, output);
91+
if (currentContainer.Count > 0)
92+
currentContainer.Pop();
93+
}
94+
}
95+
96+
// Process first page header and footer, if enabled.
97+
// If not enabled, header/footer for first page should be ignored if present.
98+
if (firstPageEnabled)
99+
{
100+
// If no header/footer for first page is found in this section or the previous ones,
101+
// a blank header/footer is created.
102+
// Another header/footer type should *not* be used in its place.
103+
pageSet.HeaderFirst = new();
104+
pageSet.FooterFirst = new();
105+
106+
// Try to find header for first page
107+
var headerRefFirst = FindHeaderReference(sectionProperties, HeaderFooterValues.First);
108+
if (HeaderFooterHelpers.GetHeaderFromReference(headerRefFirst, mainPart) is Header firstHeader)
109+
{
110+
// Process header
111+
currentContainer.Push(pageSet.HeaderFirst);
112+
base.ProcessHeader(firstHeader, output);
113+
if (currentContainer.Count > 0)
114+
currentContainer.Pop();
115+
}
116+
117+
// Try to find footer for first page
118+
var footerRefFirst = FindFooterReference(sectionProperties, HeaderFooterValues.First);
119+
if (HeaderFooterHelpers.GetFooterFromReference(footerRefFirst, mainPart) is Footer firstFooter)
120+
{
121+
// Process footer
122+
currentContainer.Push(pageSet.FooterFirst);
123+
base.ProcessFooter(firstFooter, output);
124+
if (currentContainer.Count > 0)
125+
currentContainer.Pop();
126+
}
127+
}
128+
}
129+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Xml.Linq;
6+
using DocSharp.Docx;
7+
using DocumentFormat.OpenXml;
8+
using DocumentFormat.OpenXml.Packaging;
9+
using DocumentFormat.OpenXml.Wordprocessing;
10+
using W = DocumentFormat.OpenXml.Wordprocessing;
11+
using QuestPDF.Fluent;
12+
using System.Globalization;
13+
using M = DocumentFormat.OpenXml.Math;
14+
using System.Diagnostics;
15+
16+
namespace DocSharp.Renderer;
17+
18+
public partial class DocxRenderer : DocxEnumerator<QuestPdfModel>, IDocumentRenderer<QuestPDF.Fluent.Document>
19+
{
20+
internal override void ProcessMathElement(OpenXmlElement element, QuestPdfModel output)
21+
{
22+
switch (element)
23+
{
24+
case M.Paragraph oMathPara:
25+
// TODO: Ensure empty line before ?
26+
foreach (var subElement in oMathPara.Elements())
27+
{
28+
if (subElement is M.OfficeMath ||
29+
subElement is M.Run)
30+
{
31+
ProcessMathElement(subElement, output);
32+
}
33+
else if (subElement is M.ParagraphProperties oMathParaPr)
34+
{
35+
}
36+
// Math paragraphs can't contain other elements such as limits or fractions directly
37+
// (see hierarchy in the Open XML Sdk documentation).
38+
// Also, we must avoid infinite recursion.
39+
else if (!subElement.IsMathElement())
40+
{
41+
// Process word processing elements such as regular Runs.
42+
ProcessParagraphElement(subElement, output);
43+
}
44+
}
45+
break;
46+
case M.OfficeMath oMath:
47+
// Limitations:
48+
// - Regular (not math) elements inside OfficeMath and Math.Run are not supported,
49+
// except for the last element that can be taken out of the Latex block
50+
// (this way at least line breaks are supported).
51+
// To preserve formatting such as bold or color we would need to convert these to LaTex syntax.
52+
// - OfficeMath and Math.Paragraph elements nested into another OfficeMath element are not supported
53+
// (rare, I have never seen this in a real DOCX document).
54+
string latex;
55+
try
56+
{
57+
latex = MathConverter.MLConverter.ToLaTex(oMath.OuterXml);
58+
}
59+
catch (Exception ex)
60+
{
61+
// Don't stop converter if math translation fails.
62+
latex = string.Empty;
63+
#if DEBUG
64+
Debug.Write($"Math converter: {ex.Message}");
65+
#endif
66+
}
67+
if (!string.IsNullOrWhiteSpace(latex))
68+
{
69+
RenderLatex(latex, output);
70+
}
71+
if (element.LastChild != null && !element.LastChild.IsMathElement())
72+
{
73+
// Process word processing element (hyperlink, bookmark, ...)
74+
ProcessParagraphElement(element.LastChild, output);
75+
}
76+
else if (element.LastChild is M.Run run && run.LastChild != null && !run.LastChild.IsMathElement())
77+
{
78+
// Process word processing element (break, regular text, ...)
79+
ProcessRunElement(run.LastChild, output);
80+
}
81+
break;
82+
case M.Run:
83+
ProcessMathElement(new M.OfficeMath(element), output);
84+
// The last child is handled in the above case.
85+
break;
86+
case M.Accent:
87+
case M.Bar:
88+
case M.BorderBox:
89+
case M.Box:
90+
case M.Delimiter:
91+
case M.EquationArray:
92+
case M.Fraction:
93+
case M.MathFunction:
94+
case M.GroupChar:
95+
case M.LimitLower:
96+
case M.LimitUpper:
97+
case M.Matrix:
98+
case M.Nary:
99+
case M.Phantom:
100+
case M.Radical:
101+
case M.PreSubSuper:
102+
case M.Subscript:
103+
case M.Superscript:
104+
case M.SubSuperscript:
105+
ProcessMathElement(new M.OfficeMath(element), output);
106+
break;
107+
}
108+
}
109+
110+
internal void RenderLatex(string latex, QuestPdfModel output)
111+
{
112+
// TODO: render to image using CSharpMath
113+
// (requires implementation of regular images in the renderer)
114+
}
115+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Xml.Linq;
6+
using DocSharp.Docx;
7+
using DocumentFormat.OpenXml;
8+
using DocumentFormat.OpenXml.Packaging;
9+
using DocumentFormat.OpenXml.Wordprocessing;
10+
using W = DocumentFormat.OpenXml.Wordprocessing;
11+
using QuestPDF.Fluent;
12+
using System.Globalization;
13+
using M = DocumentFormat.OpenXml.Math;
14+
using System.Diagnostics;
15+
16+
namespace DocSharp.Renderer;
17+
18+
public partial class DocxRenderer : DocxEnumerator<QuestPdfModel>, IDocumentRenderer<QuestPDF.Fluent.Document>
19+
{
20+
internal override void ProcessDrawing(Drawing picture, QuestPdfModel output)
21+
{
22+
}
23+
24+
internal override void ProcessVml(OpenXmlElement picture, QuestPdfModel output)
25+
{
26+
}
27+
}

0 commit comments

Comments
 (0)