feat: Import 35+ skills, merge duplicates, add openclaw installer

Major updates:
- Added 35+ new skills from awesome-opencode-skills and antigravity repos
- Merged SEO skills into seo-master
- Merged architecture skills into architecture
- Merged security skills into security-auditor and security-coder
- Merged testing skills into testing-master and testing-patterns
- Merged pentesting skills into pentesting
- Renamed website-creator to thai-frontend-dev
- Replaced skill-creator with github version
- Removed Chutes references (use MiniMax API instead)
- Added install-openclaw-skills.sh for cross-platform installation
- Updated .env.example with MiniMax API credentials
This commit is contained in:
Kunthawat Greethong
2026-03-26 11:37:39 +07:00
parent 48595100a1
commit 7edf5bc4d0
469 changed files with 131580 additions and 417 deletions

View File

@@ -0,0 +1,147 @@
using System.CommandLine;
using System.IO.Compression;
using System.Text.Json;
using System.Xml.Linq;
namespace MiniMaxAIDocx.Core.Commands;
public static class AnalyzeCommand
{
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
private static readonly XNamespace WP = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
public static Command Create()
{
var inputOption = new Option<string>("--input") { Description = "DOCX file to analyze", Required = true };
var jsonOption = new Option<bool>("--json") { Description = "Output as JSON" };
var cmd = new Command("analyze", "Analyze document structure and styles")
{
inputOption, jsonOption
};
cmd.SetAction((parseResult) =>
{
var input = parseResult.GetValue(inputOption)!;
var asJson = parseResult.GetValue(jsonOption);
if (!File.Exists(input))
{
Console.Error.WriteLine($"File not found: {input}");
return;
}
using var zip = ZipFile.OpenRead(input);
var docEntry = zip.GetEntry("word/document.xml");
if (docEntry == null)
{
Console.Error.WriteLine("Not a valid DOCX");
return;
}
XDocument doc;
using (var stream = docEntry.Open())
doc = XDocument.Load(stream);
var body = doc.Root?.Element(W + "body");
if (body == null) return;
// Sections
var sections = body.Descendants(W + "sectPr").ToList();
var sectionBreaks = sections.Select(s => (string?)s.Element(W + "type")?.Attribute(W + "val") ?? "nextPage").ToList();
// Headings
var headings = new List<object>();
foreach (var p in body.Descendants(W + "p"))
{
var style = (string?)p.Element(W + "pPr")?.Element(W + "pStyle")?.Attribute(W + "val");
if (style?.StartsWith("Heading", StringComparison.OrdinalIgnoreCase) == true)
{
var text = string.Concat(p.Descendants(W + "t").Select(t => t.Value));
headings.Add(new { style, text });
}
}
// Tables
var tables = body.Descendants(W + "tbl").Select(tbl => new
{
rows = tbl.Elements(W + "tr").Count(),
cols = tbl.Elements(W + "tr").FirstOrDefault()?.Elements(W + "tc").Count() ?? 0
}).ToList();
// Images
var images = body.Descendants(W + "drawing").Count();
// Headers/footers
var headerRefs = sections.SelectMany(s => s.Elements(W + "headerReference")).Count();
var footerRefs = sections.SelectMany(s => s.Elements(W + "footerReference")).Count();
// Paragraphs and word count
var paragraphs = body.Descendants(W + "p").ToList();
var allText = string.Concat(body.Descendants(W + "t").Select(t => t.Value));
var wordCount = allText.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).Length;
// XML file sizes
var fileSizes = zip.Entries
.Where(e => e.FullName.StartsWith("word/") && e.FullName.EndsWith(".xml"))
.Select(e => new { file = e.FullName, size = e.Length })
.OrderByDescending(e => e.size)
.ToList();
// Styles
var styleNames = new List<string>();
var stylesEntry = zip.GetEntry("word/styles.xml");
if (stylesEntry != null)
{
using var stream = stylesEntry.Open();
var stylesDoc = XDocument.Load(stream);
styleNames = stylesDoc.Descendants(W + "style")
.Where(s => (string?)s.Attribute(W + "customStyle") == "1")
.Select(s => (string?)s.Attribute(W + "styleId") ?? "")
.Where(s => s != "")
.ToList();
}
var analysis = new
{
sections = new { count = sections.Count, breakTypes = sectionBreaks },
headings,
tables = new { count = tables.Count, details = tables },
images,
headerFooter = new { headers = headerRefs, footers = footerRefs },
paragraphs = paragraphs.Count,
estimatedWordCount = wordCount,
xmlFileSizes = fileSizes,
customStyles = new { count = styleNames.Count, names = styleNames }
};
if (asJson)
{
Console.WriteLine(JsonSerializer.Serialize(analysis, new JsonSerializerOptions { WriteIndented = true }));
}
else
{
Console.WriteLine($"Sections: {sections.Count} ({string.Join(", ", sectionBreaks)})");
Console.WriteLine($"Headings: {headings.Count}");
foreach (var h in headings)
Console.WriteLine($" {h}");
Console.WriteLine($"Tables: {tables.Count}");
foreach (var t in tables)
Console.WriteLine($" {t.rows} rows x {t.cols} cols");
Console.WriteLine($"Images: {images}");
Console.WriteLine($"Headers: {headerRefs}");
Console.WriteLine($"Footers: {footerRefs}");
Console.WriteLine($"Paragraphs: {paragraphs.Count}");
Console.WriteLine($"Word count: ~{wordCount}");
Console.WriteLine($"Custom styles: {styleNames.Count}");
foreach (var s in styleNames)
Console.WriteLine($" {s}");
Console.WriteLine("XML file sizes:");
foreach (var f in fileSizes)
Console.WriteLine($" {f.file}: {f.size:N0} bytes");
}
});
return cmd;
}
}

View File

@@ -0,0 +1,322 @@
using System.CommandLine;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace MiniMaxAIDocx.Core.Commands;
/// <summary>
/// Scenario C: Apply formatting from a template DOCX to a source DOCX.
/// Copies styles, theme, numbering, headers/footers, and section properties
/// from the template while preserving all content from the source.
/// </summary>
public static class ApplyTemplateCommand
{
public static Command Create()
{
var inputOpt = new Option<string>("--input") { Description = "Source DOCX (content to keep)", Required = true };
var templateOpt = new Option<string>("--template") { Description = "Template DOCX (formatting to apply)", Required = true };
var outputOpt = new Option<string>("--output") { Description = "Output DOCX file path", Required = true };
var applyStylesOpt = new Option<bool>("--apply-styles") { Description = "Copy styles.xml from template" };
applyStylesOpt.DefaultValueFactory = _ => true;
var applyThemeOpt = new Option<bool>("--apply-theme") { Description = "Copy theme from template" };
applyThemeOpt.DefaultValueFactory = _ => true;
var applyNumberingOpt = new Option<bool>("--apply-numbering") { Description = "Copy numbering.xml from template" };
applyNumberingOpt.DefaultValueFactory = _ => true;
var applyHeadersFootersOpt = new Option<bool>("--apply-headers-footers") { Description = "Copy headers/footers from template" };
var applySectionsOpt = new Option<bool>("--apply-sections") { Description = "Apply section properties from template" };
applySectionsOpt.DefaultValueFactory = _ => true;
var cmd = new Command("apply-template", "Apply template formatting to a DOCX")
{
inputOpt, templateOpt, outputOpt, applyStylesOpt, applyThemeOpt,
applyNumberingOpt, applyHeadersFootersOpt, applySectionsOpt
};
cmd.SetAction((parseResult) =>
{
var inputPath = parseResult.GetValue(inputOpt)!;
var templatePath = parseResult.GetValue(templateOpt)!;
var outputPath = parseResult.GetValue(outputOpt)!;
var applyStyles = parseResult.GetValue(applyStylesOpt);
var applyTheme = parseResult.GetValue(applyThemeOpt);
var applyNumbering = parseResult.GetValue(applyNumberingOpt);
var applyHeadersFooters = parseResult.GetValue(applyHeadersFootersOpt);
var applySections = parseResult.GetValue(applySectionsOpt);
if (!File.Exists(inputPath)) { Console.Error.WriteLine($"Input file not found: {inputPath}"); return; }
if (!File.Exists(templatePath)) { Console.Error.WriteLine($"Template file not found: {templatePath}"); return; }
// Create output as a copy of the source
File.Copy(inputPath, outputPath, overwrite: true);
using var output = WordprocessingDocument.Open(outputPath, true);
using var template = WordprocessingDocument.Open(templatePath, false);
var outputMain = output.MainDocumentPart;
var templateMain = template.MainDocumentPart;
if (outputMain == null || templateMain == null)
{
Console.Error.WriteLine("Invalid document: missing main document part.");
return;
}
int appliedCount = 0;
if (applyStyles)
{
CopyStyles(templateMain, outputMain);
appliedCount++;
Console.WriteLine(" Applied: styles");
}
if (applyTheme)
{
CopyTheme(templateMain, outputMain);
appliedCount++;
Console.WriteLine(" Applied: theme");
}
if (applyNumbering)
{
CopyNumbering(templateMain, outputMain);
appliedCount++;
Console.WriteLine(" Applied: numbering");
}
if (applyHeadersFooters)
{
CopyHeadersAndFooters(templateMain, outputMain);
appliedCount++;
Console.WriteLine(" Applied: headers/footers");
}
if (applySections)
{
CopySectionProperties(templateMain, outputMain);
appliedCount++;
Console.WriteLine(" Applied: section properties");
}
outputMain.Document.Save();
Console.WriteLine($"Applied {appliedCount} formatting component(s) from template to {outputPath}");
});
return cmd;
}
/// <summary>
/// Replaces the output's StyleDefinitionsPart with the template's version.
/// </summary>
private static void CopyStyles(MainDocumentPart template, MainDocumentPart output)
{
var templateStyles = template.StyleDefinitionsPart;
if (templateStyles == null) return;
if (output.StyleDefinitionsPart != null)
output.DeletePart(output.StyleDefinitionsPart);
var newStylesPart = output.AddNewPart<StyleDefinitionsPart>();
using var stream = templateStyles.GetStream(FileMode.Open, FileAccess.Read);
newStylesPart.FeedData(stream);
}
/// <summary>
/// Replaces the output's ThemePart with the template's version.
/// </summary>
private static void CopyTheme(MainDocumentPart template, MainDocumentPart output)
{
var templateTheme = template.ThemePart;
if (templateTheme == null) return;
if (output.ThemePart != null)
output.DeletePart(output.ThemePart);
var newThemePart = output.AddNewPart<ThemePart>();
using var stream = templateTheme.GetStream(FileMode.Open, FileAccess.Read);
newThemePart.FeedData(stream);
}
/// <summary>
/// Copies numbering definitions from template, remapping numbering IDs
/// referenced in the output document's paragraphs.
/// </summary>
private static void CopyNumbering(MainDocumentPart template, MainDocumentPart output)
{
var templateNumbering = template.NumberingDefinitionsPart;
if (templateNumbering == null) return;
var referencedNumIds = new HashSet<string>();
var body = output.Document.Body;
if (body != null)
{
foreach (var numId in body.Descendants<NumberingId>())
{
if (numId.Val?.Value != null)
referencedNumIds.Add(numId.Val.Value.ToString());
}
}
if (output.NumberingDefinitionsPart != null)
output.DeletePart(output.NumberingDefinitionsPart);
var newNumberingPart = output.AddNewPart<NumberingDefinitionsPart>();
using var stream = templateNumbering.GetStream(FileMode.Open, FileAccess.Read);
newNumberingPart.FeedData(stream);
if (referencedNumIds.Count > 0)
{
Console.WriteLine($" Note: {referencedNumIds.Count} numbering reference(s) in document content mapped to template definitions.");
}
}
/// <summary>
/// Copies headers and footers from the template, remapping relationship IDs.
/// </summary>
private static void CopyHeadersAndFooters(MainDocumentPart template, MainDocumentPart output)
{
var outputBody = output.Document.Body;
if (outputBody == null) return;
// Remove existing header/footer parts from output
foreach (var hp in output.HeaderParts.ToList())
output.DeletePart(hp);
foreach (var fp in output.FooterParts.ToList())
output.DeletePart(fp);
// Remove existing header/footer references from all section properties
foreach (var sectPr in outputBody.Descendants<SectionProperties>())
{
foreach (var hr in sectPr.Elements<HeaderReference>().ToList())
hr.Remove();
foreach (var fr in sectPr.Elements<FooterReference>().ToList())
fr.Remove();
}
var templateBody = template.Document?.Body;
if (templateBody == null) return;
var templateFinalSectPr = templateBody.Descendants<SectionProperties>().LastOrDefault();
if (templateFinalSectPr == null) return;
var outputFinalSectPr = outputBody.Descendants<SectionProperties>().LastOrDefault();
if (outputFinalSectPr == null)
{
outputFinalSectPr = new SectionProperties();
outputBody.Append(outputFinalSectPr);
}
// Copy headers
foreach (var headerRef in templateFinalSectPr.Elements<HeaderReference>())
{
var templateHeaderPart = template.GetPartById(headerRef.Id!) as HeaderPart;
if (templateHeaderPart == null) continue;
var newHeaderPart = output.AddNewPart<HeaderPart>();
using (var stream = templateHeaderPart.GetStream(FileMode.Open, FileAccess.Read))
{
newHeaderPart.FeedData(stream);
}
CopyPartRelationships(templateHeaderPart, newHeaderPart);
var newRefId = output.GetIdOfPart(newHeaderPart);
outputFinalSectPr.InsertAt(new HeaderReference
{
Type = headerRef.Type,
Id = newRefId
}, 0);
}
// Copy footers
foreach (var footerRef in templateFinalSectPr.Elements<FooterReference>())
{
var templateFooterPart = template.GetPartById(footerRef.Id!) as FooterPart;
if (templateFooterPart == null) continue;
var newFooterPart = output.AddNewPart<FooterPart>();
using (var stream = templateFooterPart.GetStream(FileMode.Open, FileAccess.Read))
{
newFooterPart.FeedData(stream);
}
CopyPartRelationships(templateFooterPart, newFooterPart);
var newRefId = output.GetIdOfPart(newFooterPart);
var lastHeaderRef = outputFinalSectPr.Elements<HeaderReference>().LastOrDefault();
if (lastHeaderRef != null)
lastHeaderRef.InsertAfterSelf(new FooterReference { Type = footerRef.Type, Id = newRefId });
else
outputFinalSectPr.InsertAt(new FooterReference { Type = footerRef.Type, Id = newRefId }, 0);
}
}
/// <summary>
/// Copies sub-relationships (images, etc.) from a source part to a target part.
/// </summary>
private static void CopyPartRelationships(OpenXmlPart source, OpenXmlPart target)
{
foreach (var rel in source.ExternalRelationships)
{
target.AddExternalRelationship(rel.RelationshipType, rel.Uri, rel.Id);
}
foreach (var childPart in source.Parts)
{
try
{
var contentType = childPart.OpenXmlPart.ContentType;
if (contentType.StartsWith("image/"))
{
var newChild = target.AddNewPart<ImagePart>(contentType, childPart.RelationshipId);
using var stream = childPart.OpenXmlPart.GetStream(FileMode.Open, FileAccess.Read);
newChild.FeedData(stream);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WARN] Skipped non-image embedded part: {ex.Message}");
}
}
}
/// <summary>
/// Copies page size, margins, columns, and document grid from template section properties.
/// </summary>
private static void CopySectionProperties(MainDocumentPart template, MainDocumentPart output)
{
var templateBody = template.Document?.Body;
var outputBody = output.Document?.Body;
if (templateBody == null || outputBody == null) return;
var templateSectPr = templateBody.Descendants<SectionProperties>().LastOrDefault();
if (templateSectPr == null) return;
var outputSectPr = outputBody.Descendants<SectionProperties>().LastOrDefault();
if (outputSectPr == null)
{
outputSectPr = new SectionProperties();
outputBody.Append(outputSectPr);
}
CopyChildElement<PageSize>(templateSectPr, outputSectPr);
CopyChildElement<PageMargin>(templateSectPr, outputSectPr);
CopyChildElement<Columns>(templateSectPr, outputSectPr);
CopyChildElement<DocGrid>(templateSectPr, outputSectPr);
CopyChildElement<PageBorders>(templateSectPr, outputSectPr);
}
private static void CopyChildElement<T>(SectionProperties source, SectionProperties target) where T : OpenXmlElement
{
var sourceElement = source.GetFirstChild<T>();
if (sourceElement == null) return;
var existing = target.GetFirstChild<T>();
existing?.Remove();
target.Append((T)sourceElement.CloneNode(true));
}
}

View File

@@ -0,0 +1,324 @@
using System.CommandLine;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using MiniMaxAIDocx.Core.OpenXml;
using MiniMaxAIDocx.Core.Typography;
namespace MiniMaxAIDocx.Core.Commands;
/// <summary>
/// Scenario A: Create a new DOCX document from scratch with proper styles, sections,
/// headers/footers, and typography defaults.
/// </summary>
public static class CreateCommand
{
public static Command Create()
{
var outputOption = new Option<string>("--output") { Description = "Output DOCX file path", Required = true };
var typeOption = new Option<string>("--type") { Description = "Document type: report, letter, memo, academic" };
typeOption.DefaultValueFactory = _ => "report";
var titleOption = new Option<string>("--title") { Description = "Document title" };
var authorOption = new Option<string>("--author") { Description = "Document author" };
var pageSizeOption = new Option<string>("--page-size") { Description = "Page size: letter, a4, legal, a3" };
pageSizeOption.DefaultValueFactory = _ => "letter";
var marginsOption = new Option<string>("--margins") { Description = "Margin preset: standard, narrow, wide" };
marginsOption.DefaultValueFactory = _ => "standard";
var headerTextOption = new Option<string>("--header") { Description = "Header text" };
var footerTextOption = new Option<string>("--footer") { Description = "Footer text" };
var pageNumbersOption = new Option<bool>("--page-numbers") { Description = "Add page numbers in footer" };
var tocOption = new Option<bool>("--toc") { Description = "Insert table of contents placeholder" };
var contentJsonOption = new Option<string>("--content-json") { Description = "Path to JSON file describing document content" };
var cmd = new Command("create", "Create a new DOCX document from scratch")
{
outputOption, typeOption, titleOption, authorOption, pageSizeOption,
marginsOption, headerTextOption, footerTextOption, pageNumbersOption,
tocOption, contentJsonOption
};
cmd.SetAction((parseResult) =>
{
var output = parseResult.GetValue(outputOption)!;
var docType = parseResult.GetValue(typeOption) ?? "report";
var title = parseResult.GetValue(titleOption);
var author = parseResult.GetValue(authorOption);
var pageSizeName = parseResult.GetValue(pageSizeOption) ?? "letter";
var marginsName = parseResult.GetValue(marginsOption) ?? "standard";
var headerText = parseResult.GetValue(headerTextOption);
var footerText = parseResult.GetValue(footerTextOption);
var pageNumbers = parseResult.GetValue(pageNumbersOption);
var tocPlaceholder = parseResult.GetValue(tocOption);
var contentJson = parseResult.GetValue(contentJsonOption);
var fontConfig = GetFontConfig(docType);
var pageSize = GetPageSizeConfig(pageSizeName);
var margins = GetMargins(marginsName);
using var doc = WordprocessingDocument.Create(output, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new Document(new Body());
var body = mainPart.Document.Body!;
// Add styles part with defaults
AddDefaultStyles(mainPart, fontConfig);
// Add section properties (page size, margins)
var sectPr = new SectionProperties();
sectPr.Append(new DocumentFormat.OpenXml.Wordprocessing.PageSize
{
Width = (UInt32Value)(uint)pageSize.WidthDxa,
Height = (UInt32Value)(uint)pageSize.HeightDxa
});
sectPr.Append(new PageMargin
{
Top = margins.TopDxa,
Bottom = margins.BottomDxa,
Left = (UInt32Value)(uint)margins.LeftDxa,
Right = (UInt32Value)(uint)margins.RightDxa
});
// Add header if requested
if (!string.IsNullOrEmpty(headerText))
{
var headerPart = mainPart.AddNewPart<HeaderPart>();
headerPart.Header = new Header(
new Paragraph(new Run(new Text(headerText))));
var headerRefId = mainPart.GetIdOfPart(headerPart);
sectPr.Append(new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = headerRefId
});
}
// Add footer if requested
if (!string.IsNullOrEmpty(footerText) || pageNumbers)
{
var footerPart = mainPart.AddNewPart<FooterPart>();
var footerParagraph = new Paragraph();
if (!string.IsNullOrEmpty(footerText))
{
footerParagraph.Append(new Run(new Text(footerText)));
}
if (pageNumbers)
{
if (!string.IsNullOrEmpty(footerText))
footerParagraph.Append(new Run(new Text(" — ") { Space = SpaceProcessingModeValues.Preserve }));
footerParagraph.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.Begin }));
footerParagraph.Append(new Run(
new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve }));
footerParagraph.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.End }));
}
footerPart.Footer = new Footer(footerParagraph);
var footerRefId = mainPart.GetIdOfPart(footerPart);
sectPr.Append(new FooterReference
{
Type = HeaderFooterValues.Default,
Id = footerRefId
});
}
// Title
if (!string.IsNullOrEmpty(title))
{
var titlePara = new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "Title" }),
new Run(new Text(title)));
body.Append(titlePara);
}
// Author subtitle
if (!string.IsNullOrEmpty(author))
{
var authorPara = new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "Subtitle" }),
new Run(new Text(author)));
body.Append(authorPara);
}
// TOC placeholder
if (tocPlaceholder)
{
body.Append(new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "TOCHeading" }),
new Run(new Text("Table of Contents"))));
// Insert TOC field
var tocPara = new Paragraph();
tocPara.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin }));
tocPara.Append(new Run(new FieldCode(" TOC \\o \"1-3\" \\h \\z \\u ") { Space = SpaceProcessingModeValues.Preserve }));
tocPara.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Separate }));
tocPara.Append(new Run(new Text("Update this field to generate table of contents.")));
tocPara.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End }));
body.Append(tocPara);
// Page break after TOC
body.Append(new Paragraph(new Run(new Break { Type = BreakValues.Page })));
}
// Content from JSON (if provided)
if (!string.IsNullOrEmpty(contentJson) && File.Exists(contentJson))
{
var jsonContent = File.ReadAllText(contentJson);
AddContentFromJson(body, jsonContent, fontConfig);
}
// Ensure body has at least one paragraph
if (!body.Elements<Paragraph>().Any())
{
body.Append(new Paragraph());
}
// sectPr must be the last child of body
body.Append(sectPr);
mainPart.Document.Save();
Console.WriteLine($"Created {docType} document: {output}");
});
return cmd;
}
private static FontConfig GetFontConfig(string docType) => docType.ToLowerInvariant() switch
{
"letter" => FontDefaults.Letter,
"memo" => FontDefaults.Memo,
"academic" => FontDefaults.Academic,
_ => FontDefaults.Report,
};
private static Typography.PageSize GetPageSizeConfig(string name) => name.ToLowerInvariant() switch
{
"a4" => PageSizes.A4,
"legal" => PageSizes.Legal,
"a3" => PageSizes.A3,
_ => PageSizes.Letter,
};
private static MarginConfig GetMargins(string name) => name.ToLowerInvariant() switch
{
"narrow" => PageSizes.NarrowMargins,
"wide" => PageSizes.WideMargins,
_ => PageSizes.StandardMargins,
};
private static void AddDefaultStyles(MainDocumentPart mainPart, FontConfig fontConfig)
{
var stylesPart = mainPart.AddNewPart<StyleDefinitionsPart>();
var styles = new Styles();
// Default run properties
var defaultRPr = new StyleRunProperties(
new RunFonts { Ascii = fontConfig.BodyFont, HighAnsi = fontConfig.BodyFont },
new FontSize { Val = UnitConverter.FontSizeToSz(fontConfig.BodySize) },
new FontSizeComplexScript { Val = UnitConverter.FontSizeToSz(fontConfig.BodySize) });
// Normal style
styles.Append(new Style(
new StyleName { Val = "Normal" },
new PrimaryStyle(),
defaultRPr)
{ Type = StyleValues.Paragraph, StyleId = "Normal", Default = true });
// Heading styles 1-6
double[] headingSizes = [fontConfig.Heading1Size, fontConfig.Heading2Size, fontConfig.Heading3Size,
fontConfig.Heading4Size, fontConfig.Heading5Size, fontConfig.Heading6Size];
for (int i = 0; i < 6; i++)
{
var level = i + 1;
var headingStyle = new Style(
new StyleName { Val = $"heading {level}" },
new BasedOn { Val = "Normal" },
new NextParagraphStyle { Val = "Normal" },
new PrimaryStyle(),
new StyleParagraphProperties(
new KeepNext(),
new KeepLines(),
new SpacingBetweenLines { Before = "240", After = "120" },
new OutlineLevel { Val = i }),
new StyleRunProperties(
new RunFonts { Ascii = fontConfig.HeadingFont, HighAnsi = fontConfig.HeadingFont },
new FontSize { Val = UnitConverter.FontSizeToSz(headingSizes[i]) },
new FontSizeComplexScript { Val = UnitConverter.FontSizeToSz(headingSizes[i]) },
new Bold()))
{ Type = StyleValues.Paragraph, StyleId = $"Heading{level}" };
styles.Append(headingStyle);
}
// Title style
styles.Append(new Style(
new StyleName { Val = "Title" },
new BasedOn { Val = "Normal" },
new NextParagraphStyle { Val = "Normal" },
new PrimaryStyle(),
new StyleParagraphProperties(
new Justification { Val = JustificationValues.Center },
new SpacingBetweenLines { After = "300" }),
new StyleRunProperties(
new RunFonts { Ascii = fontConfig.HeadingFont, HighAnsi = fontConfig.HeadingFont },
new FontSize { Val = UnitConverter.FontSizeToSz(fontConfig.Heading1Size + 6) },
new FontSizeComplexScript { Val = UnitConverter.FontSizeToSz(fontConfig.Heading1Size + 6) }))
{ Type = StyleValues.Paragraph, StyleId = "Title" });
// Subtitle style
styles.Append(new Style(
new StyleName { Val = "Subtitle" },
new BasedOn { Val = "Normal" },
new NextParagraphStyle { Val = "Normal" },
new StyleParagraphProperties(
new Justification { Val = JustificationValues.Center },
new SpacingBetweenLines { After = "200" }),
new StyleRunProperties(
new Color { Val = "5A5A5A" },
new FontSize { Val = UnitConverter.FontSizeToSz(fontConfig.BodySize + 2) }))
{ Type = StyleValues.Paragraph, StyleId = "Subtitle" });
stylesPart.Styles = styles;
stylesPart.Styles.Save();
}
private static void AddContentFromJson(Body body, string jsonContent, FontConfig fontConfig)
{
// Simple JSON content format: array of {type, text, level?}
// e.g. [{"type":"heading","text":"Introduction","level":1},{"type":"paragraph","text":"..."}]
try
{
using var jsonDoc = System.Text.Json.JsonDocument.Parse(jsonContent);
foreach (var element in jsonDoc.RootElement.EnumerateArray())
{
var type = element.GetProperty("type").GetString() ?? "paragraph";
var text = element.GetProperty("text").GetString() ?? "";
switch (type)
{
case "heading":
var level = element.TryGetProperty("level", out var lvl) ? lvl.GetInt32() : 1;
level = Math.Clamp(level, 1, 6);
body.Append(new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = $"Heading{level}" }),
new Run(new Text(text))));
break;
case "paragraph":
body.Append(new Paragraph(new Run(new Text(text))));
break;
case "pagebreak":
body.Append(new Paragraph(new Run(new Break { Type = BreakValues.Page })));
break;
}
}
}
catch (System.Text.Json.JsonException ex)
{
Console.Error.WriteLine($"Warning: could not parse content JSON: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,155 @@
using System.CommandLine;
using System.IO.Compression;
using System.Text.Json;
using System.Xml.Linq;
namespace MiniMaxAIDocx.Core.Commands;
public static class DiffCommand
{
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
public static Command Create()
{
var beforeOption = new Option<string>("--before") { Description = "Original DOCX", Required = true };
var afterOption = new Option<string>("--after") { Description = "Modified DOCX", Required = true };
var jsonOption = new Option<bool>("--json") { Description = "Output as JSON" };
var cmd = new Command("diff", "Compare two DOCX files")
{
beforeOption, afterOption, jsonOption
};
cmd.SetAction((parseResult) =>
{
var before = parseResult.GetValue(beforeOption)!;
var after = parseResult.GetValue(afterOption)!;
var asJson = parseResult.GetValue(jsonOption);
if (!File.Exists(before)) { Console.Error.WriteLine($"File not found: {before}"); return; }
if (!File.Exists(after)) { Console.Error.WriteLine($"File not found: {after}"); return; }
var beforeParas = ExtractParagraphs(before);
var afterParas = ExtractParagraphs(after);
var beforeStyles = ExtractStyleIds(before);
var afterStyles = ExtractStyleIds(after);
var beforeStructure = ExtractStructure(before);
var afterStructure = ExtractStructure(after);
// Text diff
var textChanges = new List<object>();
int maxLen = Math.Max(beforeParas.Count, afterParas.Count);
int changedParas = 0;
for (int i = 0; i < maxLen; i++)
{
var bText = i < beforeParas.Count ? beforeParas[i] : null;
var aText = i < afterParas.Count ? afterParas[i] : null;
if (bText != aText)
{
changedParas++;
textChanges.Add(new
{
paragraph = i + 1,
before = bText ?? "(absent)",
after = aText ?? "(absent)"
});
}
}
// Style diff
var addedStyles = afterStyles.Except(beforeStyles).ToList();
var removedStyles = beforeStyles.Except(afterStyles).ToList();
// Structure diff
var structureChanges = new List<string>();
if (beforeStructure.Sections != afterStructure.Sections)
structureChanges.Add($"Sections: {beforeStructure.Sections} -> {afterStructure.Sections}");
if (beforeStructure.Tables != afterStructure.Tables)
structureChanges.Add($"Tables: {beforeStructure.Tables} -> {afterStructure.Tables}");
if (beforeStructure.Images != afterStructure.Images)
structureChanges.Add($"Images: {beforeStructure.Images} -> {afterStructure.Images}");
var result = new
{
textChanges,
styleChanges = new { added = addedStyles, removed = removedStyles },
structureChanges,
summary = $"{changedParas} paragraphs changed, {addedStyles.Count + removedStyles.Count} styles modified, {structureChanges.Count} structural changes"
};
if (asJson)
{
Console.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
}
else
{
Console.WriteLine(result.summary);
Console.WriteLine();
if (textChanges.Count > 0)
{
Console.WriteLine($"Text changes ({textChanges.Count}):");
foreach (var tc in textChanges.Take(20))
Console.WriteLine($" {tc}");
if (textChanges.Count > 20)
Console.WriteLine($" ... and {textChanges.Count - 20} more");
}
if (addedStyles.Count > 0)
Console.WriteLine($"Added styles: {string.Join(", ", addedStyles)}");
if (removedStyles.Count > 0)
Console.WriteLine($"Removed styles: {string.Join(", ", removedStyles)}");
foreach (var sc in structureChanges)
Console.WriteLine($"Structure: {sc}");
}
});
return cmd;
}
private static List<string> ExtractParagraphs(string docxPath)
{
using var zip = ZipFile.OpenRead(docxPath);
var entry = zip.GetEntry("word/document.xml");
if (entry == null) return new();
using var stream = entry.Open();
var doc = XDocument.Load(stream);
return doc.Descendants(W + "p")
.Select(p => string.Concat(p.Descendants(W + "t").Select(t => t.Value)))
.ToList();
}
private static HashSet<string> ExtractStyleIds(string docxPath)
{
using var zip = ZipFile.OpenRead(docxPath);
var entry = zip.GetEntry("word/styles.xml");
if (entry == null) return new();
using var stream = entry.Open();
var doc = XDocument.Load(stream);
return doc.Descendants(W + "style")
.Select(s => (string?)s.Attribute(W + "styleId"))
.Where(id => id != null)
.ToHashSet()!;
}
private record StructureInfo(int Sections, int Tables, int Images);
private static StructureInfo ExtractStructure(string docxPath)
{
using var zip = ZipFile.OpenRead(docxPath);
var entry = zip.GetEntry("word/document.xml");
if (entry == null) return new(0, 0, 0);
using var stream = entry.Open();
var doc = XDocument.Load(stream);
return new(
doc.Descendants(W + "sectPr").Count(),
doc.Descendants(W + "tbl").Count(),
doc.Descendants(W + "drawing").Count()
);
}
}

View File

@@ -0,0 +1,487 @@
using System.CommandLine;
using System.Text.RegularExpressions;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using MiniMaxAIDocx.Core.OpenXml;
namespace MiniMaxAIDocx.Core.Commands;
/// <summary>
/// Scenario B: Surgical content editing operations on existing DOCX files.
/// Preserves all existing formatting and minimizes XML changes.
/// </summary>
public static class EditContentCommand
{
public static Command Create()
{
var cmd = new Command("edit", "Edit existing DOCX content");
cmd.Add(CreateReplaceTextCommand());
cmd.Add(CreateFillTableCommand());
cmd.Add(CreateInsertParagraphCommand());
cmd.Add(CreateUpdateFieldCommand());
cmd.Add(CreateListPlaceholdersCommand());
cmd.Add(CreateFillPlaceholdersCommand());
return cmd;
}
private static Command CreateReplaceTextCommand()
{
var inputOpt = new Option<string>("--input") { Description = "Input DOCX file", Required = true };
var outputOpt = new Option<string>("--output") { Description = "Output file path (defaults to overwriting input)" };
var searchOpt = new Option<string>("--search") { Description = "Text to search for", Required = true };
var replaceOpt = new Option<string>("--replace") { Description = "Replacement text", Required = true };
var regexOpt = new Option<bool>("--regex") { Description = "Treat search as a regex pattern" };
var cmd = new Command("replace-text", "Replace text while preserving formatting")
{
inputOpt, outputOpt, searchOpt, replaceOpt, regexOpt
};
cmd.SetAction((parseResult) =>
{
var input = parseResult.GetValue(inputOpt)!;
var output = parseResult.GetValue(outputOpt) ?? input;
var search = parseResult.GetValue(searchOpt)!;
var replace = parseResult.GetValue(replaceOpt)!;
var useRegex = parseResult.GetValue(regexOpt);
if (output != input) File.Copy(input, output, overwrite: true);
using var doc = WordprocessingDocument.Open(output, true);
var body = doc.MainDocumentPart?.Document.Body;
if (body == null) { Console.Error.WriteLine("No document body found."); return; }
int count = 0;
foreach (var paragraph in body.Descendants<Paragraph>())
{
count += ReplaceInParagraph(paragraph, search, replace, useRegex);
}
doc.MainDocumentPart!.Document.Save();
Console.WriteLine($"Replaced {count} occurrence(s) in {output}");
});
return cmd;
}
private static Command CreateFillTableCommand()
{
var inputOpt = new Option<string>("--input") { Description = "Input DOCX file", Required = true };
var outputOpt = new Option<string>("--output") { Description = "Output file path" };
var tableIndexOpt = new Option<int>("--table-index") { Description = "Zero-based index of the table to fill" };
tableIndexOpt.DefaultValueFactory = _ => 0;
var csvOpt = new Option<string>("--csv") { Description = "CSV file with data to fill", Required = true };
var appendOpt = new Option<bool>("--append") { Description = "Append rows instead of replacing existing data rows" };
var cmd = new Command("fill-table", "Fill a table with data from CSV")
{
inputOpt, outputOpt, tableIndexOpt, csvOpt, appendOpt
};
cmd.SetAction((parseResult) =>
{
var input = parseResult.GetValue(inputOpt)!;
var output = parseResult.GetValue(outputOpt) ?? input;
var tableIndex = parseResult.GetValue(tableIndexOpt);
var csvPath = parseResult.GetValue(csvOpt)!;
var append = parseResult.GetValue(appendOpt);
if (output != input) File.Copy(input, output, overwrite: true);
if (!File.Exists(csvPath)) { Console.Error.WriteLine($"CSV file not found: {csvPath}"); return; }
using var doc = WordprocessingDocument.Open(output, true);
var body = doc.MainDocumentPart?.Document.Body;
if (body == null) { Console.Error.WriteLine("No document body found."); return; }
var tables = body.Elements<Table>().ToList();
if (tableIndex >= tables.Count)
{
Console.Error.WriteLine($"Table index {tableIndex} out of range (found {tables.Count} tables).");
return;
}
var table = tables[tableIndex];
var csvLines = File.ReadAllLines(csvPath);
if (csvLines.Length == 0) { Console.WriteLine("CSV is empty, nothing to fill."); return; }
// Get template row properties from the first data row (second row, after header)
var existingRows = table.Elements<TableRow>().ToList();
TableRow? templateRow = existingRows.Count > 1 ? existingRows[1] : existingRows.FirstOrDefault();
var templateTrPr = templateRow?.TableRowProperties?.CloneNode(true) as TableRowProperties;
if (!append)
{
// Remove all rows except the header row
for (int i = existingRows.Count - 1; i >= 1; i--)
existingRows[i].Remove();
}
int rowsAdded = 0;
// Skip header line in CSV (index 0)
for (int i = 1; i < csvLines.Length; i++)
{
var values = ParseCsvLine(csvLines[i]);
var newRow = new TableRow();
if (templateTrPr != null)
newRow.Append(templateTrPr.CloneNode(true));
foreach (var val in values)
{
var cell = new TableCell(
new Paragraph(new Run(new Text(val))));
newRow.Append(cell);
}
table.Append(newRow);
rowsAdded++;
}
doc.MainDocumentPart!.Document.Save();
Console.WriteLine($"Added {rowsAdded} rows to table {tableIndex} in {output}");
});
return cmd;
}
private static Command CreateInsertParagraphCommand()
{
var inputOpt = new Option<string>("--input") { Description = "Input DOCX file", Required = true };
var outputOpt = new Option<string>("--output") { Description = "Output file path" };
var textOpt = new Option<string>("--text") { Description = "Paragraph text", Required = true };
var styleOpt = new Option<string>("--style") { Description = "Paragraph style (e.g. Heading1, Normal)" };
var afterOpt = new Option<int>("--after-paragraph") { Description = "Insert after this paragraph index (0-based)" };
afterOpt.DefaultValueFactory = _ => -1; // -1 = append at end
var cmd = new Command("insert-paragraph", "Insert a new paragraph")
{
inputOpt, outputOpt, textOpt, styleOpt, afterOpt
};
cmd.SetAction((parseResult) =>
{
var input = parseResult.GetValue(inputOpt)!;
var output = parseResult.GetValue(outputOpt) ?? input;
var text = parseResult.GetValue(textOpt)!;
var style = parseResult.GetValue(styleOpt);
var afterIndex = parseResult.GetValue(afterOpt);
if (output != input) File.Copy(input, output, overwrite: true);
using var doc = WordprocessingDocument.Open(output, true);
var body = doc.MainDocumentPart?.Document.Body;
if (body == null) { Console.Error.WriteLine("No document body found."); return; }
var newPara = new Paragraph();
if (!string.IsNullOrEmpty(style))
newPara.Append(new ParagraphProperties(new ParagraphStyleId { Val = style }));
newPara.Append(new Run(new Text(text)));
var paragraphs = body.Elements<Paragraph>().ToList();
if (afterIndex >= 0 && afterIndex < paragraphs.Count)
{
paragraphs[afterIndex].InsertAfterSelf(newPara);
}
else
{
// Insert before sectPr if present, otherwise append
var sectPr = body.Elements<SectionProperties>().FirstOrDefault();
if (sectPr != null)
sectPr.InsertBeforeSelf(newPara);
else
body.Append(newPara);
}
doc.MainDocumentPart!.Document.Save();
Console.WriteLine($"Inserted paragraph in {output}");
});
return cmd;
}
private static Command CreateUpdateFieldCommand()
{
var inputOpt = new Option<string>("--input") { Description = "Input DOCX file", Required = true };
var outputOpt = new Option<string>("--output") { Description = "Output file path" };
var fieldNameOpt = new Option<string>("--field") { Description = "Document property field name (e.g. TITLE, AUTHOR)", Required = true };
var valueOpt = new Option<string>("--value") { Description = "New field value", Required = true };
var cmd = new Command("update-field", "Update a document property field value")
{
inputOpt, outputOpt, fieldNameOpt, valueOpt
};
cmd.SetAction((parseResult) =>
{
var input = parseResult.GetValue(inputOpt)!;
var output = parseResult.GetValue(outputOpt) ?? input;
var fieldName = parseResult.GetValue(fieldNameOpt)!;
var value = parseResult.GetValue(valueOpt)!;
if (output != input) File.Copy(input, output, overwrite: true);
using var doc = WordprocessingDocument.Open(output, true);
// Update core properties
var props = doc.PackageProperties;
switch (fieldName.ToUpperInvariant())
{
case "TITLE": props.Title = value; break;
case "AUTHOR": props.Creator = value; break;
case "SUBJECT": props.Subject = value; break;
case "KEYWORDS": props.Keywords = value; break;
case "DESCRIPTION": props.Description = value; break;
case "CATEGORY": props.Category = value; break;
default:
Console.Error.WriteLine($"Unknown field: {fieldName}. Supported: TITLE, AUTHOR, SUBJECT, KEYWORDS, DESCRIPTION, CATEGORY");
return;
}
Console.WriteLine($"Updated {fieldName} to \"{value}\" in {output}");
});
return cmd;
}
private static Command CreateListPlaceholdersCommand()
{
var inputOpt = new Option<string>("--input") { Description = "Input DOCX file", Required = true };
var patternOpt = new Option<string>("--pattern") { Description = "Placeholder pattern (regex)" };
patternOpt.DefaultValueFactory = _ => @"\{\{(\w+)\}\}"; // {{PLACEHOLDER}}
var cmd = new Command("list-placeholders", "List all placeholders found in the document")
{
inputOpt, patternOpt
};
cmd.SetAction((parseResult) =>
{
var input = parseResult.GetValue(inputOpt)!;
var pattern = parseResult.GetValue(patternOpt)!;
using var doc = WordprocessingDocument.Open(input, false);
var body = doc.MainDocumentPart?.Document.Body;
if (body == null) { Console.Error.WriteLine("No document body found."); return; }
var placeholders = new HashSet<string>();
var regex = new Regex(pattern);
foreach (var paragraph in body.Descendants<Paragraph>())
{
var fullText = string.Concat(paragraph.Descendants<Text>().Select(t => t.Text));
foreach (Match match in regex.Matches(fullText))
{
placeholders.Add(match.Value);
}
}
if (placeholders.Count == 0)
{
Console.WriteLine("No placeholders found.");
return;
}
Console.WriteLine($"Found {placeholders.Count} unique placeholder(s):");
foreach (var p in placeholders.OrderBy(x => x))
Console.WriteLine($" {p}");
});
return cmd;
}
private static Command CreateFillPlaceholdersCommand()
{
var inputOpt = new Option<string>("--input") { Description = "Input DOCX file", Required = true };
var outputOpt = new Option<string>("--output") { Description = "Output file path" };
var mappingOpt = new Option<string>("--mapping") { Description = "JSON file mapping placeholder names to values", Required = true };
var patternOpt = new Option<string>("--pattern") { Description = "Placeholder pattern with capture group for the name" };
patternOpt.DefaultValueFactory = _ => @"\{\{(\w+)\}\}";
var cmd = new Command("fill-placeholders", "Replace placeholders with values from a mapping file")
{
inputOpt, outputOpt, mappingOpt, patternOpt
};
cmd.SetAction((parseResult) =>
{
var input = parseResult.GetValue(inputOpt)!;
var output = parseResult.GetValue(outputOpt) ?? input;
var mappingPath = parseResult.GetValue(mappingOpt)!;
var pattern = parseResult.GetValue(patternOpt)!;
if (!File.Exists(mappingPath)) { Console.Error.WriteLine($"Mapping file not found: {mappingPath}"); return; }
var mappingJson = File.ReadAllText(mappingPath);
Dictionary<string, string> mapping;
try
{
mapping = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(mappingJson) ?? [];
}
catch (System.Text.Json.JsonException ex)
{
Console.Error.WriteLine($"Invalid mapping JSON: {ex.Message}");
return;
}
if (output != input) File.Copy(input, output, overwrite: true);
using var doc = WordprocessingDocument.Open(output, true);
var body = doc.MainDocumentPart?.Document.Body;
if (body == null) { Console.Error.WriteLine("No document body found."); return; }
int totalReplacements = 0;
var regex = new Regex(pattern);
foreach (var paragraph in body.Descendants<Paragraph>())
{
var fullText = string.Concat(paragraph.Descendants<Text>().Select(t => t.Text));
var matches = regex.Matches(fullText);
if (matches.Count == 0) continue;
foreach (Match match in matches)
{
var placeholderName = match.Groups.Count > 1 ? match.Groups[1].Value : match.Value;
if (mapping.TryGetValue(placeholderName, out var replacement))
{
totalReplacements += ReplaceInParagraph(paragraph, match.Value, replacement, false);
}
}
}
doc.MainDocumentPart!.Document.Save();
Console.WriteLine($"Filled {totalReplacements} placeholder(s) in {output}");
});
return cmd;
}
/// <summary>
/// Replaces text within a paragraph while preserving run formatting.
/// Handles the case where search text may span multiple runs.
/// </summary>
private static int ReplaceInParagraph(Paragraph paragraph, string search, string replace, bool useRegex)
{
var runs = paragraph.Elements<Run>().ToList();
if (runs.Count == 0) return 0;
// Build the full paragraph text and a map from character index to (run, position within run)
var fullText = string.Concat(runs.SelectMany(r => r.Elements<Text>().Select(t => t.Text)));
if (string.IsNullOrEmpty(fullText)) return 0;
int count = 0;
if (!useRegex)
{
// Simple case: search within each run first
foreach (var run in runs)
{
foreach (var textElement in run.Elements<Text>().ToList())
{
if (textElement.Text.Contains(search))
{
var newText = textElement.Text.Replace(search, replace);
count += (textElement.Text.Length - newText.Length + replace.Length - search.Length) == 0 ? 0 :
CountOccurrences(textElement.Text, search);
textElement.Text = newText;
if (newText.StartsWith(' ') || newText.EndsWith(' '))
textElement.Space = SpaceProcessingModeValues.Preserve;
}
}
}
// Handle cross-run matches by concatenating all runs, replacing, and rebuilding
if (count == 0 && fullText.Contains(search))
{
var newFullText = fullText.Replace(search, replace);
count = CountOccurrences(fullText, search);
RebuildRunsWithText(paragraph, runs, newFullText);
}
}
else
{
var regex = new Regex(search);
if (regex.IsMatch(fullText))
{
count = regex.Matches(fullText).Count;
var newFullText = regex.Replace(fullText, replace);
RebuildRunsWithText(paragraph, runs, newFullText);
}
}
return count;
}
/// <summary>
/// Replaces the text content of existing runs with new text,
/// preserving the formatting of the first run.
/// </summary>
private static void RebuildRunsWithText(Paragraph paragraph, List<Run> runs, string newText)
{
if (runs.Count == 0) return;
// Keep the first run's formatting, set its text to the full new text
var firstRun = runs[0];
var firstText = firstRun.Elements<Text>().FirstOrDefault();
if (firstText != null)
{
firstText.Text = newText;
if (newText.StartsWith(' ') || newText.EndsWith(' '))
firstText.Space = SpaceProcessingModeValues.Preserve;
}
// Remove all other runs
for (int i = 1; i < runs.Count; i++)
runs[i].Remove();
}
private static int CountOccurrences(string text, string search)
{
int count = 0;
int index = 0;
while ((index = text.IndexOf(search, index, StringComparison.Ordinal)) != -1)
{
count++;
index += search.Length;
}
return count;
}
private static string[] ParseCsvLine(string line)
{
// Simple CSV parser (handles quoted fields)
var result = new List<string>();
bool inQuotes = false;
var current = new System.Text.StringBuilder();
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (c == '"')
{
if (inQuotes && i + 1 < line.Length && line[i + 1] == '"')
{
current.Append('"');
i++;
}
else
{
inQuotes = !inQuotes;
}
}
else if (c == ',' && !inQuotes)
{
result.Add(current.ToString());
current.Clear();
}
else
{
current.Append(c);
}
}
result.Add(current.ToString());
return result.ToArray();
}
}

View File

@@ -0,0 +1,108 @@
using System.CommandLine;
using System.IO.Compression;
using System.Xml.Linq;
namespace MiniMaxAIDocx.Core.Commands;
public static class FixOrderCommand
{
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
// Canonical element ordering within common parent elements per ISO 29500
private static readonly Dictionary<string, List<string>> ElementOrder = new()
{
["pPr"] = new() { "pStyle", "keepNext", "keepLines", "pageBreakBefore", "widowControl", "numPr", "suppressLineNumbers", "pBdr", "shd", "tabs", "suppressAutoHyphens", "spacing", "ind", "jc", "outlineLvl", "rPr" },
["rPr"] = new() { "rStyle", "rFonts", "b", "bCs", "i", "iCs", "caps", "smallCaps", "strike", "dstrike", "vanish", "color", "spacing", "w", "kern", "position", "sz", "szCs", "highlight", "u", "effect", "vertAlign", "lang" },
["tblPr"] = new() { "tblStyle", "tblpPr", "tblOverlap", "tblW", "jc", "tblInd", "tblBorders", "shd", "tblLayout", "tblCellMar", "tblLook" },
["tcPr"] = new() { "cnfStyle", "tcW", "gridSpan", "hMerge", "vMerge", "tcBorders", "shd", "noWrap", "tcMar", "textDirection", "tcFitText", "vAlign" },
["sectPr"] = new() { "headerReference", "footerReference", "footnotePr", "endnotePr", "type", "pgSz", "pgMar", "paperSrc", "pgBorders", "lnNumType", "pgNumType", "cols", "docGrid" },
};
public static Command Create()
{
var inputOption = new Option<string>("--input") { Description = "DOCX file to fix", Required = true };
var outputOption = new Option<string>("--output") { Description = "Output path (default: overwrite input)" };
var backupOption = new Option<bool>("--backup") { Description = "Create .bak before modifying", DefaultValueFactory = (_) => true };
var cmd = new Command("fix-order", "Fix OpenXML element ordering per ISO 29500")
{
inputOption, outputOption, backupOption
};
cmd.SetAction((parseResult) =>
{
var input = parseResult.GetValue(inputOption)!;
var output = parseResult.GetValue(outputOption) ?? input;
var backup = parseResult.GetValue(backupOption);
if (!File.Exists(input))
{
Console.Error.WriteLine($"File not found: {input}");
return;
}
if (backup && output == input)
File.Copy(input, input + ".bak", true);
var tempPath = Path.GetTempFileName();
File.Copy(input, tempPath, true);
using var zip = ZipFile.Open(tempPath, ZipArchiveMode.Update);
var entry = zip.GetEntry("word/document.xml");
if (entry == null)
{
Console.Error.WriteLine("Not a valid DOCX");
return;
}
XDocument doc;
using (var stream = entry.Open())
doc = XDocument.Load(stream);
int reorderedCount = 0;
foreach (var (parentName, order) in ElementOrder)
{
foreach (var parent in doc.Descendants(W + parentName))
{
var children = parent.Elements().ToList();
var sorted = children.OrderBy(e =>
{
var idx = order.IndexOf(e.Name.LocalName);
return idx >= 0 ? idx : order.Count;
}).ToList();
bool changed = false;
for (int i = 0; i < children.Count; i++)
{
if (children[i] != sorted[i])
{
changed = true;
break;
}
}
if (changed)
{
parent.ReplaceNodes(sorted);
reorderedCount++;
}
}
}
entry.Delete();
var newEntry = zip.CreateEntry("word/document.xml", CompressionLevel.Optimal);
using (var stream = newEntry.Open())
doc.Save(stream);
zip.Dispose();
File.Copy(tempPath, output, true);
File.Delete(tempPath);
Console.WriteLine($"Reordered {reorderedCount} element group(s)");
Console.WriteLine($"Written to: {output}");
});
return cmd;
}
}

View File

@@ -0,0 +1,122 @@
using System.CommandLine;
using System.IO.Compression;
using System.Xml.Linq;
namespace MiniMaxAIDocx.Core.Commands;
public static class MergeRunsCommand
{
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
public static Command Create()
{
var inputOption = new Option<string>("--input") { Description = "DOCX file to optimize", Required = true };
var outputOption = new Option<string>("--output") { Description = "Output path (default: overwrite input)" };
var dryRunOption = new Option<bool>("--dry-run") { Description = "Report without modifying" };
var cmd = new Command("merge-runs", "Merge adjacent runs with identical formatting")
{
inputOption, outputOption, dryRunOption
};
cmd.SetAction((parseResult) =>
{
var input = parseResult.GetValue(inputOption)!;
var output = parseResult.GetValue(outputOption) ?? input;
var dryRun = parseResult.GetValue(dryRunOption);
if (!File.Exists(input))
{
Console.Error.WriteLine($"File not found: {input}");
return;
}
var tempPath = Path.GetTempFileName();
File.Copy(input, tempPath, true);
using var zip = ZipFile.Open(tempPath, ZipArchiveMode.Update);
var entry = zip.GetEntry("word/document.xml");
if (entry == null)
{
Console.Error.WriteLine("Not a valid DOCX: missing word/document.xml");
return;
}
XDocument doc;
using (var stream = entry.Open())
doc = XDocument.Load(stream);
int originalCount = 0;
int mergedCount = 0;
foreach (var p in doc.Descendants(W + "p"))
{
var runs = p.Elements(W + "r").ToList();
originalCount += runs.Count;
for (int i = runs.Count - 1; i > 0; i--)
{
var current = runs[i];
var previous = runs[i - 1];
var curProps = current.Element(W + "rPr")?.ToString() ?? "";
var prevProps = previous.Element(W + "rPr")?.ToString() ?? "";
if (curProps == prevProps)
{
// Only merge if both contain only text elements
var curChildren = current.Elements().Where(e => e.Name != W + "rPr").ToList();
var prevChildren = previous.Elements().Where(e => e.Name != W + "rPr").ToList();
if (curChildren.All(e => e.Name == W + "t") && prevChildren.All(e => e.Name == W + "t"))
{
var prevText = previous.Elements(W + "t").LastOrDefault();
var curText = current.Elements(W + "t").FirstOrDefault();
if (prevText != null && curText != null)
{
prevText.Value += curText.Value;
prevText.SetAttributeValue(XNamespace.Xml + "space", "preserve");
foreach (var extra in current.Elements(W + "t").Skip(1))
{
previous.Add(new XElement(extra));
}
current.Remove();
runs.RemoveAt(i);
}
}
}
}
mergedCount += runs.Count;
}
if (dryRun)
{
Console.WriteLine($"Original runs: {originalCount}");
Console.WriteLine($"After merge: {mergedCount}");
Console.WriteLine($"Reduction: {(originalCount > 0 ? (originalCount - mergedCount) * 100.0 / originalCount : 0):F1}%");
File.Delete(tempPath);
return;
}
entry.Delete();
var newEntry = zip.CreateEntry("word/document.xml", CompressionLevel.Optimal);
using (var stream = newEntry.Open())
doc.Save(stream);
zip.Dispose();
File.Copy(tempPath, output, true);
File.Delete(tempPath);
Console.WriteLine($"Original runs: {originalCount}");
Console.WriteLine($"After merge: {mergedCount}");
Console.WriteLine($"Reduction: {(originalCount > 0 ? (originalCount - mergedCount) * 100.0 / originalCount : 0):F1}%");
Console.WriteLine($"Written to: {output}");
});
return cmd;
}
}

View File

@@ -0,0 +1,107 @@
using System.CommandLine;
using System.Text.Json;
using MiniMaxAIDocx.Core.Validation;
namespace MiniMaxAIDocx.Core.Commands;
public static class ValidateCommand
{
public static Command Create()
{
var inputOption = new Option<string>("--input") { Description = "DOCX file to validate", Required = true };
var xsdOption = new Option<string>("--xsd") { Description = "XSD schema path for XML validation" };
var businessOption = new Option<bool>("--business") { Description = "Run business rule validation" };
var gateCheckOption = new Option<string>("--gate-check") { Description = "Template DOCX for gate-check validation" };
var jsonOption = new Option<bool>("--json") { Description = "Output results as JSON" };
var cmd = new Command("validate", "Validate DOCX structure and content")
{
inputOption, xsdOption, businessOption, gateCheckOption, jsonOption
};
cmd.SetAction((parseResult) =>
{
var input = parseResult.GetValue(inputOption)!;
var xsd = parseResult.GetValue(xsdOption);
var business = parseResult.GetValue(businessOption);
var gateCheck = parseResult.GetValue(gateCheckOption);
var asJson = parseResult.GetValue(jsonOption);
if (!File.Exists(input))
{
Console.Error.WriteLine($"File not found: {input}");
return;
}
var combinedResult = new ValidationResult();
GateCheckResult? gateResult = null;
if (xsd != null)
{
var xsdValidator = new XsdValidator();
combinedResult.Merge(xsdValidator.Validate(input, xsd));
}
if (business)
{
var bizValidator = new BusinessRuleValidator();
combinedResult.Merge(bizValidator.Validate(input));
}
if (gateCheck != null)
{
var gateValidator = new GateCheckValidator();
gateResult = gateValidator.Validate(input, gateCheck);
}
if (asJson)
{
var output = new
{
isValid = combinedResult.IsValid && (gateResult?.Passed ?? true),
errors = combinedResult.Errors,
warnings = combinedResult.Warnings,
gateCheck = gateResult == null ? null : new
{
passed = gateResult.Passed,
violations = gateResult.Violations
}
};
Console.WriteLine(JsonSerializer.Serialize(output, new JsonSerializerOptions { WriteIndented = true }));
}
else
{
if (combinedResult.Errors.Count > 0)
{
Console.WriteLine($"ERRORS ({combinedResult.Errors.Count}):");
foreach (var e in combinedResult.Errors)
Console.WriteLine($" [{e.Severity}] {e.Message}" + (e.LineNumber > 0 ? $" (line {e.LineNumber}:{e.LinePosition})" : ""));
}
if (combinedResult.Warnings.Count > 0)
{
Console.WriteLine($"WARNINGS ({combinedResult.Warnings.Count}):");
foreach (var w in combinedResult.Warnings)
Console.WriteLine($" [{w.Severity}] {w.Message}");
}
if (gateResult != null)
{
Console.WriteLine(gateResult.Passed ? "GATE CHECK: PASSED" : "GATE CHECK: FAILED");
foreach (var v in gateResult.Violations)
Console.WriteLine($" - {v}");
}
if (combinedResult.IsValid && (gateResult?.Passed ?? true))
Console.WriteLine("Validation: PASSED");
else
Console.WriteLine("Validation: FAILED");
}
if (!combinedResult.IsValid || gateResult is { Passed: false })
Environment.ExitCode = 1;
});
return cmd;
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NeutralLanguage>en</NeutralLanguage>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="System.CommandLine" Version="2.0.5" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,169 @@
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace MiniMaxAIDocx.Core.OpenXml;
/// <summary>
/// Manages the 4-file comment system (comments.xml, commentsExtended.xml,
/// commentsIds.xml, commentsExtensible.xml) plus document.xml markers.
/// </summary>
public static class CommentSynchronizer
{
/// <summary>
/// Adds a comment to the document, updating all required parts.
/// </summary>
public static int AddComment(WordprocessingDocument doc, string text, string author, string rangeBookmark)
{
var mainPart = doc.MainDocumentPart
?? throw new InvalidOperationException("Document has no main part.");
int commentId = GetNextCommentId(doc);
// Ensure comments part exists
var commentsPart = mainPart.WordprocessingCommentsPart
?? mainPart.AddNewPart<WordprocessingCommentsPart>();
if (commentsPart.Comments == null)
commentsPart.Comments = new Comments();
// Create the comment
var comment = new Comment
{
Id = commentId.ToString(),
Author = author,
Date = DateTime.UtcNow,
Initials = author.Length > 0 ? author[..1].ToUpperInvariant() : "A"
};
comment.Append(new Paragraph(new Run(new Text(text))));
commentsPart.Comments.Append(comment);
// Add range markers in document body
var body = mainPart.Document.Body;
if (body != null)
{
// Find bookmark or append at end
var rangeStart = new CommentRangeStart { Id = commentId.ToString() };
var rangeEnd = new CommentRangeEnd { Id = commentId.ToString() };
var reference = new Run(new CommentReference { Id = commentId.ToString() });
body.Append(rangeStart);
body.Append(rangeEnd);
body.Append(new Paragraph(reference));
}
return commentId;
}
/// <summary>
/// Adds a reply to an existing comment.
/// </summary>
public static int AddReply(WordprocessingDocument doc, int parentCommentId, string text, string author)
{
var mainPart = doc.MainDocumentPart
?? throw new InvalidOperationException("Document has no main part.");
var commentsPart = mainPart.WordprocessingCommentsPart
?? throw new InvalidOperationException("Document has no comments part.");
int replyId = GetNextCommentId(doc);
var reply = new Comment
{
Id = replyId.ToString(),
Author = author,
Date = DateTime.UtcNow,
Initials = author.Length > 0 ? author[..1].ToUpperInvariant() : "A"
};
reply.Append(new Paragraph(new Run(new Text(text))));
commentsPart.Comments?.Append(reply);
// Link reply to parent via commentsExtended.xml
LinkReplyToParent(doc, replyId, parentCommentId);
return replyId;
}
/// <summary>
/// Marks a comment as resolved/done by setting done="1" in commentsExtended.xml.
/// Uses raw XML manipulation since these extended parts lack typed SDK support.
/// </summary>
public static void ResolveComment(WordprocessingDocument doc, int commentId)
{
var mainPart = doc.MainDocumentPart;
if (mainPart == null) return;
// commentsExtended.xml is an untyped part — manipulate via raw XML
const string ceUri = "http://schemas.microsoft.com/office/word/2018/wordml/cex";
foreach (var part in mainPart.Parts)
{
if (part.OpenXmlPart.ContentType.Contains("commentsExtensible"))
{
using var stream = part.OpenXmlPart.GetStream(FileMode.Open, FileAccess.ReadWrite);
var xdoc = System.Xml.Linq.XDocument.Load(stream);
var ns = System.Xml.Linq.XNamespace.Get(ceUri);
var commentEl = xdoc.Descendants(ns + "comment")
.FirstOrDefault(e => e.Attribute(ns + "paraId")?.Value != null);
// Set done flag if element found for this comment
if (commentEl != null)
{
commentEl.SetAttributeValue("done", "1");
stream.SetLength(0);
xdoc.Save(stream);
}
return;
}
}
}
/// <summary>
/// Links a reply comment to its parent via commentsExtended.xml (w15:commentEx).
/// Uses raw XML since the extended comment parts lack typed SDK support.
/// </summary>
private static void LinkReplyToParent(WordprocessingDocument doc, int replyId, int parentCommentId)
{
var mainPart = doc.MainDocumentPart;
if (mainPart == null) return;
const string w15Uri = "http://schemas.microsoft.com/office/word/2012/wordml";
var w15 = System.Xml.Linq.XNamespace.Get(w15Uri);
// Find or create commentsExtended part
foreach (var part in mainPart.Parts)
{
if (part.OpenXmlPart.ContentType.Contains("commentsExtended"))
{
using var stream = part.OpenXmlPart.GetStream(FileMode.Open, FileAccess.ReadWrite);
var xdoc = System.Xml.Linq.XDocument.Load(stream);
var root = xdoc.Root;
if (root == null) return;
root.Add(new System.Xml.Linq.XElement(w15 + "commentEx",
new System.Xml.Linq.XAttribute(w15 + "paraId", replyId.ToString("X8")),
new System.Xml.Linq.XAttribute(w15 + "paraIdParent", parentCommentId.ToString("X8")),
new System.Xml.Linq.XAttribute(w15 + "done", "0")));
stream.SetLength(0);
xdoc.Save(stream);
return;
}
}
}
/// <summary>
/// Finds the maximum existing comment ID and returns the next one.
/// </summary>
public static int GetNextCommentId(WordprocessingDocument doc)
{
var commentsPart = doc.MainDocumentPart?.WordprocessingCommentsPart;
if (commentsPart?.Comments == null) return 1;
int maxId = 0;
foreach (var comment in commentsPart.Comments.Elements<Comment>())
{
if (comment.Id?.Value != null && int.TryParse(comment.Id.Value, out int id) && id > maxId)
maxId = id;
}
return maxId + 1;
}
}

View File

@@ -0,0 +1,80 @@
using System.Xml.Linq;
namespace MiniMaxAIDocx.Core.OpenXml;
/// <summary>
/// Defines canonical child element ordering for key OpenXML parent elements
/// and provides reordering utilities.
/// </summary>
public static class ElementOrder
{
private static readonly Dictionary<string, string[]> OrderMap = new()
{
["w:body"] = ["w:p", "w:tbl", "w:sdt", "w:sectPr"],
["w:p"] = ["w:pPr", "w:hyperlink", "w:r", "w:ins", "w:del", "w:bookmarkStart", "w:bookmarkEnd", "w:commentRangeStart", "w:commentRangeEnd", "w:fldSimple"],
["w:pPr"] = ["w:pStyle", "w:keepNext", "w:keepLines", "w:pageBreakBefore", "w:widowControl", "w:numPr", "w:pBdr", "w:shd", "w:tabs", "w:suppressAutoHyphens", "w:spacing", "w:ind", "w:jc", "w:rPr", "w:sectPr", "w:pPrChange"],
["w:r"] = ["w:rPr", "w:t", "w:br", "w:tab", "w:cr", "w:sym", "w:drawing", "w:delText", "w:fldChar", "w:instrText", "w:lastRenderedPageBreak", "w:noBreakHyphen", "w:softHyphen"],
["w:rPr"] = ["w:rStyle", "w:rFonts", "w:b", "w:bCs", "w:i", "w:iCs", "w:caps", "w:smallCaps", "w:strike", "w:dstrike", "w:vanish", "w:color", "w:sz", "w:szCs", "w:u", "w:shd", "w:highlight", "w:lang", "w:rPrChange"],
["w:tbl"] = ["w:tblPr", "w:tblGrid", "w:tr"],
["w:tblPr"] = ["w:tblStyle", "w:tblpPr", "w:tblOverlap", "w:tblW", "w:jc", "w:tblCellSpacing", "w:tblInd", "w:tblBorders", "w:shd", "w:tblLayout", "w:tblCellMar", "w:tblLook", "w:tblPrChange"],
["w:tr"] = ["w:trPr", "w:tc"],
["w:trPr"] = ["w:cnfStyle", "w:divId", "w:gridBefore", "w:gridAfter", "w:wBefore", "w:wAfter", "w:cantSplit", "w:trHeight", "w:tblHeader", "w:tblCellSpacing", "w:jc", "w:hidden", "w:ins", "w:del", "w:trPrChange"],
["w:tc"] = ["w:tcPr", "w:p", "w:tbl"],
["w:tcPr"] = ["w:cnfStyle", "w:tcW", "w:gridSpan", "w:hMerge", "w:vMerge", "w:tcBorders", "w:shd", "w:noWrap", "w:tcMar", "w:textDirection", "w:tcFitText", "w:vAlign", "w:hideMark", "w:headers", "w:cellIns", "w:cellDel", "w:cellMerge", "w:tcPrChange"],
["w:sectPr"] = ["w:headerReference", "w:footerReference", "w:type", "w:pgSz", "w:pgMar", "w:paperSrc", "w:pgBorders", "w:lnNumType", "w:pgNumType", "w:cols", "w:formProt", "w:vAlign", "w:noEndnote", "w:titlePg", "w:textDirection", "w:bidi", "w:rtlGutter", "w:docGrid"],
["w:hdr"] = ["w:p", "w:tbl", "w:sdt"],
["w:ftr"] = ["w:p", "w:tbl", "w:sdt"],
};
/// <summary>
/// Returns the canonical child ordering for a given parent element name (e.g. "w:p").
/// Returns null if no ordering is defined.
/// </summary>
public static string[]? GetChildOrder(string parentElement)
{
return OrderMap.TryGetValue(parentElement, out var order) ? order : null;
}
/// <summary>
/// Reorders children of the given XElement according to the canonical ordering rules.
/// Children not listed in the ordering are placed at the end in their original order.
/// </summary>
public static void ReorderChildren(XElement parent)
{
var qualifiedName = GetQualifiedName(parent);
var order = GetChildOrder(qualifiedName);
if (order == null) return;
var children = parent.Elements().ToList();
if (children.Count <= 1) return;
var orderIndex = new Dictionary<string, int>();
for (int i = 0; i < order.Length; i++)
orderIndex[order[i]] = i;
int unknownBase = order.Length;
int unknownCounter = 0;
var sorted = children
.Select(c => (Element: c, QName: GetQualifiedName(c)))
.OrderBy(x => orderIndex.TryGetValue(x.QName, out var idx) ? idx : unknownBase + unknownCounter++)
.Select(x => x.Element)
.ToList();
parent.RemoveNodes();
foreach (var child in sorted)
parent.Add(child);
}
private static string GetQualifiedName(XElement element)
{
var ns = element.Name.Namespace;
var local = element.Name.LocalName;
if (ns == Ns.W) return $"w:{local}";
if (ns == Ns.R) return $"r:{local}";
if (ns == Ns.MC) return $"mc:{local}";
return local;
}
}

View File

@@ -0,0 +1,42 @@
using System.Xml.Linq;
namespace MiniMaxAIDocx.Core.OpenXml;
/// <summary>
/// All OpenXML namespace URIs and common content/relationship type constants.
/// </summary>
public static class Ns
{
public static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
public static readonly XNamespace R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
public static readonly XNamespace WP = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
public static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main";
public static readonly XNamespace MC = "http://schemas.openxmlformats.org/markup-compatibility/2006";
public static readonly XNamespace PIC = "http://schemas.openxmlformats.org/drawingml/2006/picture";
public static readonly XNamespace W14 = "http://schemas.microsoft.com/office/word/2010/wordml";
public static readonly XNamespace W15 = "http://schemas.microsoft.com/office/word/2012/wordml";
public static readonly XNamespace W16CID = "http://schemas.microsoft.com/office/word/2016/wordml/cid";
public static readonly XNamespace W16CEX = "http://schemas.microsoft.com/office/word/2018/wordml/cex";
public static readonly XNamespace WPC = "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas";
public static readonly XNamespace WPS = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape";
// Content types
public const string MainDocumentContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
public const string StylesContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml";
public const string HeaderContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml";
public const string FooterContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml";
public const string CommentsContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml";
// Relationship types
public const string DocumentRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
public const string StylesRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
public const string HeaderRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
public const string FooterRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
public const string CommentsRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
public const string ImageRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
public const string HyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
public const string NumberingRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering";
public const string FontTableRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable";
public const string ThemeRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme";
public const string SettingsRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
}

View File

@@ -0,0 +1,81 @@
using System.Xml.Linq;
namespace MiniMaxAIDocx.Core.OpenXml;
/// <summary>
/// Result of a run merge operation.
/// </summary>
public record RunMergeResult(int OriginalRunCount, int MergedRunCount, int SizeReductionBytes);
/// <summary>
/// Merges adjacent w:r elements with identical w:rPr formatting to reduce document size.
/// </summary>
public static class RunMerger
{
/// <summary>
/// Merges adjacent runs with identical formatting in all paragraphs of the document body.
/// </summary>
public static RunMergeResult MergeRuns(XDocument document)
{
var body = document.Root?.Element(Ns.W + "body");
if (body == null) return new(0, 0, 0);
int originalCount = 0;
int removedCount = 0;
foreach (var paragraph in body.Descendants(Ns.W + "p"))
{
var runs = paragraph.Elements(Ns.W + "r").ToList();
originalCount += runs.Count;
for (int i = runs.Count - 1; i > 0; i--)
{
var current = runs[i];
var previous = runs[i - 1];
if (!AreRunPropertiesEqual(previous, current)) continue;
// Merge text content from current into previous
var prevText = GetOrCreateTextElement(previous);
var currText = current.Element(Ns.W + "t");
if (currText != null && prevText != null)
{
prevText.Value += currText.Value;
// Preserve xml:space="preserve" if either has it
if (currText.Attribute(XNamespace.Xml + "space")?.Value == "preserve" ||
prevText.Value.StartsWith(' ') || prevText.Value.EndsWith(' '))
{
prevText.SetAttributeValue(XNamespace.Xml + "space", "preserve");
}
}
current.Remove();
removedCount++;
}
}
return new(originalCount, originalCount - removedCount, 0);
}
private static bool AreRunPropertiesEqual(XElement run1, XElement run2)
{
var rPr1 = run1.Element(Ns.W + "rPr");
var rPr2 = run2.Element(Ns.W + "rPr");
if (rPr1 == null && rPr2 == null) return true;
if (rPr1 == null || rPr2 == null) return false;
return XNode.DeepEquals(rPr1, rPr2);
}
private static XElement? GetOrCreateTextElement(XElement run)
{
var t = run.Element(Ns.W + "t");
if (t == null)
{
t = new XElement(Ns.W + "t");
run.Add(t);
}
return t;
}
}

View File

@@ -0,0 +1,81 @@
using System.Xml.Linq;
namespace MiniMaxAIDocx.Core.OpenXml;
public record StyleInfo(string Id, string? Name, string Type, string? BasedOn, bool IsDefault);
public record StyleReport(
List<StyleInfo> AllStyles,
Dictionary<string, List<string>> InheritanceTree,
string? DefaultParagraphStyle,
string? DefaultCharacterStyle,
int DirectFormattingCount);
/// <summary>
/// Analyzes the style hierarchy of a DOCX document.
/// </summary>
public static class StyleAnalyzer
{
/// <summary>
/// Analyzes styles.xml content and document.xml for direct formatting usage.
/// </summary>
public static StyleReport Analyze(XDocument stylesXml, XDocument documentXml)
{
var styles = ExtractStyles(stylesXml);
var tree = BuildInheritanceTree(styles);
var defaultPara = styles.FirstOrDefault(s => s.Type == "paragraph" && s.IsDefault)?.Id;
var defaultChar = styles.FirstOrDefault(s => s.Type == "character" && s.IsDefault)?.Id;
var directCount = CountDirectFormatting(documentXml);
return new(styles, tree, defaultPara, defaultChar, directCount);
}
private static List<StyleInfo> ExtractStyles(XDocument stylesXml)
{
var result = new List<StyleInfo>();
var root = stylesXml.Root;
if (root == null) return result;
foreach (var style in root.Elements(Ns.W + "style"))
{
var id = style.Attribute(Ns.W + "styleId")?.Value ?? "";
var name = style.Element(Ns.W + "name")?.Attribute(Ns.W + "val")?.Value;
var type = style.Attribute(Ns.W + "type")?.Value ?? "unknown";
var basedOn = style.Element(Ns.W + "basedOn")?.Attribute(Ns.W + "val")?.Value;
var isDefault = style.Attribute(Ns.W + "default")?.Value == "1";
result.Add(new(id, name, type, basedOn, isDefault));
}
return result;
}
private static Dictionary<string, List<string>> BuildInheritanceTree(List<StyleInfo> styles)
{
var tree = new Dictionary<string, List<string>>();
foreach (var style in styles)
{
var parent = style.BasedOn ?? "(root)";
if (!tree.ContainsKey(parent))
tree[parent] = [];
tree[parent].Add(style.Id);
}
return tree;
}
private static int CountDirectFormatting(XDocument documentXml)
{
var body = documentXml.Root?.Element(Ns.W + "body");
if (body == null) return 0;
int count = 0;
// Count inline rPr on runs (direct character formatting)
count += body.Descendants(Ns.W + "r")
.Count(r => r.Element(Ns.W + "rPr") != null);
// Count inline pPr that contain more than just pStyle (direct paragraph formatting)
count += body.Descendants(Ns.W + "p")
.Select(p => p.Element(Ns.W + "pPr"))
.Count(pPr => pPr != null && pPr.Elements().Any(e => e.Name != Ns.W + "pStyle"));
return count;
}
}

View File

@@ -0,0 +1,99 @@
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace MiniMaxAIDocx.Core.OpenXml;
/// <summary>
/// Helpers for Track Changes (revision marks) operations.
/// </summary>
public static class TrackChangesHelper
{
/// <summary>
/// Wraps a run in a w:ins element to propose an insertion.
/// </summary>
public static InsertedRun ProposeInsertion(Run run, string author, DateTime date)
{
var ins = new InsertedRun
{
Author = author,
Date = date,
Id = run.Parent is Body body ? GetNextRevisionId(body).ToString() : "1"
};
run.Remove();
ins.Append(run);
return ins;
}
/// <summary>
/// Wraps a run in a w:del element, converting w:t to w:delText.
/// </summary>
public static DeletedRun ProposeDeletion(Run run, string author, DateTime date)
{
// Convert w:t elements to w:delText
foreach (var text in run.Elements<Text>().ToList())
{
var delText = new DeletedText { Text = text.Text, Space = SpaceProcessingModeValues.Preserve };
text.InsertAfterSelf(delText);
text.Remove();
}
var del = new DeletedRun
{
Author = author,
Date = date,
Id = run.Parent is Body body ? GetNextRevisionId(body).ToString() : "1"
};
run.Remove();
del.Append(run);
return del;
}
/// <summary>
/// Accepts an insertion by removing the w:ins wrapper and keeping content.
/// </summary>
public static void AcceptInsertion(OpenXmlElement insElement)
{
if (insElement is not InsertedRun) return;
var parent = insElement.Parent;
if (parent == null) return;
var children = insElement.ChildElements.ToList();
foreach (var child in children)
{
child.Remove();
insElement.InsertBeforeSelf(child);
}
insElement.Remove();
}
/// <summary>
/// Accepts a deletion by removing the entire w:del element and its content.
/// </summary>
public static void AcceptDeletion(OpenXmlElement delElement)
{
delElement.Remove();
}
/// <summary>
/// Finds the maximum existing revision ID in the document and returns the next one.
/// </summary>
public static int GetNextRevisionId(WordprocessingDocument doc)
{
var body = doc.MainDocumentPart?.Document?.Body;
if (body == null) return 1;
return GetNextRevisionId(body);
}
private static int GetNextRevisionId(OpenXmlElement root)
{
int maxId = 0;
foreach (var element in root.Descendants())
{
var idAttr = element.GetAttributes().FirstOrDefault(a => a.LocalName == "id");
if (idAttr.Value != null && int.TryParse(idAttr.Value, out int id) && id > maxId)
maxId = id;
}
return maxId + 1;
}
}

View File

@@ -0,0 +1,23 @@
namespace MiniMaxAIDocx.Core.OpenXml;
/// <summary>
/// Conversion utilities between OpenXML measurement units (DXA, EMU, points, half-points).
/// </summary>
public static class UnitConverter
{
// 1 inch = 1440 DXA = 914400 EMU = 72 pt = 144 half-pt
public static int InchesToDxa(double inches) => (int)(inches * 1440);
public static int CmToDxa(double cm) => (int)(cm * 567.0);
public static int PtToDxa(double pt) => (int)(pt * 20);
public static long InchesToEmu(double inches) => (long)(inches * 914400);
public static long CmToEmu(double cm) => (long)(cm * 360000);
public static int PtToHalfPt(double pt) => (int)(pt * 2);
public static string FontSizeToSz(double ptSize) => ((int)(ptSize * 2)).ToString();
public static double DxaToInches(int dxa) => dxa / 1440.0;
public static double DxaToCm(int dxa) => dxa / 567.0;
public static double DxaToPt(int dxa) => dxa / 20.0;
public static double EmuToInches(long emu) => emu / 914400.0;
public static double EmuToCm(long emu) => emu / 360000.0;
}

View File

@@ -0,0 +1,910 @@
// ============================================================================
// AestheticRecipeSamples_Batch1.cs — IEEE & ACM conference paper recipes
// ============================================================================
// Two-column academic conference styles faithfully reproducing the typographic
// conventions of IEEEtran.cls and acmart.cls for DOCX output.
//
// UNIT REFERENCE:
// Font size: half-points (20 = 10pt, 18 = 9pt, 16 = 8pt)
// Spacing: DXA = twentieths of a point (1440 DXA = 1 inch)
// Borders: eighth-points (4 = 0.5pt, 8 = 1pt, 12 = 1.5pt)
// Line spacing "line": 240ths of single spacing (240 = 1.0x)
// ============================================================================
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using WpColumns = DocumentFormat.OpenXml.Wordprocessing.Columns;
using WpPageSize = DocumentFormat.OpenXml.Wordprocessing.PageSize;
namespace MiniMaxAIDocx.Core.Samples;
public static partial class AestheticRecipeSamples
{
// ════════════════════════════════════════════════════════════════════════
// RECIPE 6: IEEE CONFERENCE (IEEEtran)
// ════════════════════════════════════════════════════════════════════════
/// <summary>
/// Recipe: IEEE Conference Paper (IEEEtran.cls v1.8b)
/// Source: IEEEtran.cls v1.8b — the standard LaTeX class for IEEE transactions
/// and conference proceedings.
///
/// Feel: Dense, formal, information-rich two-column layout.
/// Best for: IEEE conference submissions, transactions papers, technical reports
/// following IEEE style.
///
/// Design rationale (all values from IEEEtran.cls source):
/// - US Letter, narrow margins (0.625in L/R): maximizes text area for the
/// two-column layout. IEEE papers prioritize information density.
/// - Two columns with 0.25in (360 DXA) gutter: standard IEEE column separation.
/// Narrow gutter is feasible because the small font creates short line lengths.
/// - 10pt Times New Roman body (sz=20): IEEE's standard body size. TNR is the
/// required typeface. 10pt in two columns yields ~40 characters per line —
/// optimal for rapid technical reading.
/// - 24pt title, centered, NOT bold (sz=48): IEEEtran titles are large but
/// use regular weight. The size alone provides hierarchy.
/// - Section headings (H1): 10pt small caps, centered, Roman numeral prefix
/// convention (sz=20). Small caps at body size creates subtle hierarchy
/// without disrupting the dense layout.
/// - Subsection headings (H2): 10pt italic, flush left (sz=20). Italic at
/// body size is the minimal viable distinction from body text.
/// - Single spacing (line=240): mandatory for IEEE camera-ready format.
/// - First-line indent 0.125in (180 DXA): very small indent suits the narrow
/// column width.
/// - 0pt paragraph spacing: IEEE uses no inter-paragraph space; the first-line
/// indent is the sole paragraph separator.
/// - Captions: 8pt (sz=16) — subordinate to body, centered under figures/tables.
/// </summary>
public static void CreateIEEEConferenceDocument(string outputPath)
{
using var doc = WordprocessingDocument.Create(outputPath, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new Document(new Body());
var body = mainPart.Document.Body!;
// ── Styles ──
var stylesPart = mainPart.AddNewPart<StyleDefinitionsPart>();
stylesPart.Styles = new Styles();
var styles = stylesPart.Styles;
// DocDefaults: Times New Roman 10pt, single spacing, 0.125in first-line indent
styles.Append(new DocDefaults(
new RunPropertiesDefault(
new RunPropertiesBaseStyle(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman",
EastAsia = "SimSun",
ComplexScript = "Times New Roman"
},
new FontSize { Val = "20" }, // 10pt body (IEEEtran standard)
new FontSizeComplexScript { Val = "20" },
new Color { Val = "000000" }, // Pure black
new Languages { Val = "en-US", EastAsia = "zh-CN" }
)
),
new ParagraphPropertiesDefault(
new ParagraphPropertiesBaseStyle(
new SpacingBetweenLines
{
// Single spacing: mandatory for IEEE camera-ready
Line = "240",
LineRule = LineSpacingRuleValues.Auto,
After = "0",
Before = "0"
},
// First-line indent: 0.125in = 180 DXA (very small, suits narrow columns)
new Indentation { FirstLine = "180" }
)
)
));
// ── Normal style ──
styles.Append(CreateParagraphStyle(
styleId: "Normal",
styleName: "Normal",
isDefault: true,
uiPriority: 0
));
// ── Title style: 24pt centered, NOT bold ──
// IEEEtran.cls \maketitle: \LARGE (24pt at 10pt base), centered, no bold
var titleRPr = new StyleRunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman",
EastAsia = "SimSun",
ComplexScript = "Times New Roman"
},
new FontSize { Val = "48" }, // 24pt
new FontSizeComplexScript { Val = "48" },
new Color { Val = "000000" }
// No Bold — IEEEtran titles are NOT bold
);
styles.Append(new Style(
new StyleName { Val = "Title" },
new BasedOn { Val = "Normal" },
new NextParagraphStyle { Val = "Normal" },
new UIPriority { Val = 10 },
new PrimaryStyle(),
new StyleParagraphProperties(
new Justification { Val = JustificationValues.Center },
new SpacingBetweenLines { Before = "0", After = "240" },
new Indentation { FirstLine = "0" } // No indent for title
),
titleRPr
)
{
Type = StyleValues.Paragraph,
StyleId = "Title",
Default = false
});
// ── Heading 1: 10pt small caps, centered ──
// IEEEtran \section: \centering\scshape at body size, Roman numeral prefix
var h1RPr = new StyleRunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman",
EastAsia = "SimSun",
ComplexScript = "Times New Roman"
},
new FontSize { Val = "20" }, // 10pt — same as body
new FontSizeComplexScript { Val = "20" },
new Color { Val = "000000" },
new SmallCaps() // Small caps for section headings
);
styles.Append(new Style(
new StyleName { Val = "heading 1" },
new BasedOn { Val = "Normal" },
new NextParagraphStyle { Val = "Normal" },
new UIPriority { Val = 9 },
new PrimaryStyle(),
new StyleParagraphProperties(
new KeepNext(),
new KeepLines(),
new Justification { Val = JustificationValues.Center },
new SpacingBetweenLines { Before = "240", After = "120" },
new Indentation { FirstLine = "0" },
new OutlineLevel { Val = 0 }
),
h1RPr
)
{
Type = StyleValues.Paragraph,
StyleId = "Heading1",
Default = false
});
// ── Heading 2: 10pt italic, flush left ──
// IEEEtran \subsection: \itshape at body size, flush left
var h2RPr = new StyleRunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman",
EastAsia = "SimSun",
ComplexScript = "Times New Roman"
},
new FontSize { Val = "20" }, // 10pt — same as body
new FontSizeComplexScript { Val = "20" },
new Color { Val = "000000" },
new Italic() // Italic for subsection headings
);
styles.Append(new Style(
new StyleName { Val = "heading 2" },
new BasedOn { Val = "Normal" },
new NextParagraphStyle { Val = "Normal" },
new UIPriority { Val = 9 },
new PrimaryStyle(),
new StyleParagraphProperties(
new KeepNext(),
new KeepLines(),
new SpacingBetweenLines { Before = "180", After = "60" },
new Indentation { FirstLine = "0" },
new OutlineLevel { Val = 1 }
),
h2RPr
)
{
Type = StyleValues.Paragraph,
StyleId = "Heading2",
Default = false
});
// ── Abstract style: 9pt bold "Abstract" label convention ──
styles.Append(CreateParagraphStyle(
styleId: "Abstract",
styleName: "Abstract",
basedOn: "Normal",
uiPriority: 11
));
// ── Caption style: 8pt (sz=16) ──
styles.Append(CreateCaptionStyle(
fontSizeHalfPts: "16", // 8pt — IEEE standard caption size
color: "000000",
italic: false // IEEE captions are not italic
));
// ── Page setup: US Letter, IEEE margins, two-column ──
// IEEEtran.cls: top=0.75in, bottom=1in, left=right=0.625in
var sectPr = new SectionProperties(
new WpPageSize { Width = 12240U, Height = 15840U }, // US Letter
new PageMargin
{
Top = 1080, // 0.75in
Bottom = 1440, // 1in
Left = 900U, // 0.625in
Right = 900U, // 0.625in
Header = 720U, Footer = 720U, Gutter = 0U
},
// Two-column layout: 0.25in gutter = 360 DXA
new WpColumns { ColumnCount = 2, Space = "360" }
);
// ── Page numbers: bottom center, 8pt ──
AddPageNumberFooter(mainPart, sectPr,
alignment: JustificationValues.Center,
fontSizeHalfPts: "16", // 8pt
color: "000000",
format: PageNumberFormat.Plain
);
// ── Sample content: IEEE paper structure ──
// Title (spans both columns via the Title style)
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Title" }
),
new Run(new Text("Deep Learning Approaches for Automated Document Layout Analysis"))
));
// Author line (centered, no indent)
body.Append(new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Center },
new SpacingBetweenLines { After = "120" },
new Indentation { FirstLine = "0" }
),
new Run(
new RunProperties(new FontSize { Val = "20" }, new FontSizeComplexScript { Val = "20" }),
new Text("Jane A. Smith, John B. Doe, and Alice C. Johnson")
)
));
// Affiliation (centered, italic, smaller)
body.Append(new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Center },
new SpacingBetweenLines { After = "240" },
new Indentation { FirstLine = "0" }
),
new Run(
new RunProperties(
new FontSize { Val = "18" }, new FontSizeComplexScript { Val = "18" },
new Italic()
),
new Text("Department of Computer Science, Example University, City, Country")
)
));
// Abstract
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Abstract" },
new Indentation { FirstLine = "0" },
new SpacingBetweenLines { After = "120" }
),
new Run(
new RunProperties(new Bold(), new Italic(), new FontSize { Val = "18" }, new FontSizeComplexScript { Val = "18" }),
new Text("Abstract") { Space = SpaceProcessingModeValues.Preserve }
),
new Run(
new RunProperties(new FontSize { Val = "18" }, new FontSizeComplexScript { Val = "18" }),
new Text("\u2014This paper presents a comprehensive framework for automated document "
+ "layout analysis using deep learning. We propose a novel architecture that "
+ "combines convolutional neural networks with transformer-based attention "
+ "mechanisms to accurately segment and classify document regions. Experimental "
+ "results on benchmark datasets demonstrate state-of-the-art performance.")
{ Space = SpaceProcessingModeValues.Preserve }
)
));
// I. INTRODUCTION (Roman numeral convention rendered in text)
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Heading1" }
),
new Run(new Text("I. Introduction"))
));
AddSampleParagraph(body, "Document layout analysis is a fundamental step in document "
+ "understanding pipelines. The ability to automatically identify and classify "
+ "regions within a document image has applications in digitization, information "
+ "extraction, and accessibility.", "Normal");
AddSampleParagraph(body, "Recent advances in deep learning have significantly improved "
+ "the accuracy of layout analysis systems. However, challenges remain in handling "
+ "complex multi-column layouts and heterogeneous document types.", "Normal");
// II. RELATED WORK
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Heading1" }
),
new Run(new Text("II. Related Work"))
));
// A. Subsection
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Heading2" }
),
new Run(new Text("A. Traditional Methods"))
));
AddSampleParagraph(body, "Early approaches to document layout analysis relied on "
+ "rule-based methods and connected component analysis. These methods perform well "
+ "on structured documents but struggle with complex layouts.", "Normal");
// B. Subsection
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Heading2" }
),
new Run(new Text("B. Deep Learning Methods"))
));
AddSampleParagraph(body, "Convolutional neural networks have been successfully applied "
+ "to document layout analysis, achieving significant improvements over traditional "
+ "methods on standard benchmarks.", "Normal");
// III. PROPOSED METHOD
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Heading1" }
),
new Run(new Text("III. Proposed Method"))
));
AddSampleParagraph(body, "Our proposed framework integrates a feature pyramid network "
+ "backbone with a transformer decoder module. The architecture processes document "
+ "images at multiple scales to capture both fine-grained character-level features "
+ "and coarse layout structures.", "Normal");
// Table
body.Append(CreateThreeLineTable(
new[] { "Method", "Precision", "Recall", "F1" },
new[]
{
new[] { "Rule-based", "0.823", "0.791", "0.807" },
new[] { "CNN-only", "0.912", "0.887", "0.899" },
new[] { "Ours", "0.956", "0.943", "0.949" }
}
));
AddSampleParagraph(body, "TABLE I: Comparison of layout analysis methods on PubLayNet.", "Caption");
// IV. CONCLUSION
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Heading1" }
),
new Run(new Text("IV. Conclusion"))
));
AddSampleParagraph(body, "We have presented a novel deep learning framework for document "
+ "layout analysis that achieves state-of-the-art results. Future work will explore "
+ "extending the approach to handle more diverse document types.", "Normal");
// Section properties must be last child of body
body.Append(sectPr);
}
// ════════════════════════════════════════════════════════════════════════
// RECIPE 7: ACM CONFERENCE (acmart)
// ════════════════════════════════════════════════════════════════════════
/// <summary>
/// Recipe: ACM Conference Paper (acmart.cls v2.x, ACM Author Guide)
/// Source: acmart.cls v2.x — the consolidated ACM master article template,
/// and the ACM Author Guide for typographic specifications.
///
/// Feel: Clean, structured, slightly more open than IEEE.
/// Best for: ACM conference proceedings (SIGCHI, SIGMOD, SIGGRAPH, etc.),
/// ACM journal submissions.
///
/// Design rationale (all values from acmart.cls and ACM Author Guide):
/// - US Letter, 1.25in top/bottom, 0.75in L/R: more generous vertical margins
/// than IEEE, giving a less cramped appearance.
/// - Two columns with 0.33in (480 DXA) gutter: slightly wider than IEEE's
/// 0.25in, providing better visual separation between columns.
/// - 9pt Times New Roman body (sz=18): ACM's standard body size. The original
/// acmart uses Linux Libertine, but TNR is the accessible fallback specified
/// in the ACM Author Guide for systems without Libertine.
/// - 14.4pt bold title, flush left (sz=29): ACM titles are bold and left-aligned,
/// unlike IEEE's centered unbolded titles. The 14.4pt size (1.6x body) creates
/// strong but not overwhelming hierarchy.
/// - H1: 10pt bold ALL CAPS, flush left, arabic numbered (sz=20). ALL CAPS at
/// body size with bold creates definitive section breaks.
/// - H2: 10pt bold title case, flush left (sz=20). Bold without caps is the
/// minimal step down from H1.
/// - H3: 10pt bold italic, flush left (sz=20). Adding italic distinguishes
/// from H2 while maintaining the same weight.
/// - Single spacing: required for ACM camera-ready format.
/// - First-line indent ~10pt (200 DXA): slightly larger than IEEE's 0.125in,
/// matching ACM's convention of a roughly 1em indent at 9pt.
/// - Captions: 8pt (sz=16) — consistent with ACM figure/table caption style.
/// - References: 7.5pt (sz=15) — ACM uses a smaller font for the bibliography
/// to maximize space for content.
/// </summary>
public static void CreateACMConferenceDocument(string outputPath)
{
using var doc = WordprocessingDocument.Create(outputPath, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new Document(new Body());
var body = mainPart.Document.Body!;
// ── Styles ──
var stylesPart = mainPart.AddNewPart<StyleDefinitionsPart>();
stylesPart.Styles = new Styles();
var styles = stylesPart.Styles;
// DocDefaults: Times New Roman 9pt (TNR as Libertine fallback), single spacing
styles.Append(new DocDefaults(
new RunPropertiesDefault(
new RunPropertiesBaseStyle(
new RunFonts
{
// ACM specifies Linux Libertine; TNR is the accessible fallback
// per ACM Author Guide for systems without Libertine installed
Ascii = "Times New Roman",
HighAnsi = "Times New Roman",
EastAsia = "SimSun",
ComplexScript = "Times New Roman"
},
new FontSize { Val = "18" }, // 9pt body (acmart standard)
new FontSizeComplexScript { Val = "18" },
new Color { Val = "000000" }, // Pure black
new Languages { Val = "en-US", EastAsia = "zh-CN" }
)
),
new ParagraphPropertiesDefault(
new ParagraphPropertiesBaseStyle(
new SpacingBetweenLines
{
// Single spacing: ACM camera-ready requirement
Line = "240",
LineRule = LineSpacingRuleValues.Auto,
After = "0",
Before = "0"
},
// First-line indent: ~10pt = 200 DXA (roughly 1em at 9pt)
new Indentation { FirstLine = "200" }
)
)
));
// ── Normal style ──
styles.Append(CreateParagraphStyle(
styleId: "Normal",
styleName: "Normal",
isDefault: true,
uiPriority: 0
));
// ── Title style: 14.4pt bold, flush left ──
// acmart \maketitle: \LARGE\bfseries, left-aligned
var titleRPr = new StyleRunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman",
EastAsia = "SimSun",
ComplexScript = "Times New Roman"
},
new FontSize { Val = "29" }, // 14.4pt (≈29 half-points)
new FontSizeComplexScript { Val = "29" },
new Color { Val = "000000" },
new Bold() // ACM titles ARE bold
);
styles.Append(new Style(
new StyleName { Val = "Title" },
new BasedOn { Val = "Normal" },
new NextParagraphStyle { Val = "Normal" },
new UIPriority { Val = 10 },
new PrimaryStyle(),
new StyleParagraphProperties(
// Flush left — ACM titles are NOT centered
new SpacingBetweenLines { Before = "0", After = "200" },
new Indentation { FirstLine = "0" }
),
titleRPr
)
{
Type = StyleValues.Paragraph,
StyleId = "Title",
Default = false
});
// ── Heading 1: 10pt bold ALL CAPS, flush left ──
// acmart \section: \bfseries at body size, uppercase
var h1RPr = new StyleRunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman",
EastAsia = "SimSun",
ComplexScript = "Times New Roman"
},
new FontSize { Val = "20" }, // 10pt
new FontSizeComplexScript { Val = "20" },
new Color { Val = "000000" },
new Bold(),
new Caps() // ALL CAPS for H1
);
styles.Append(new Style(
new StyleName { Val = "heading 1" },
new BasedOn { Val = "Normal" },
new NextParagraphStyle { Val = "Normal" },
new UIPriority { Val = 9 },
new PrimaryStyle(),
new StyleParagraphProperties(
new KeepNext(),
new KeepLines(),
new SpacingBetweenLines { Before = "240", After = "120" },
new Indentation { FirstLine = "0" },
new OutlineLevel { Val = 0 }
),
h1RPr
)
{
Type = StyleValues.Paragraph,
StyleId = "Heading1",
Default = false
});
// ── Heading 2: 10pt bold title case, flush left ──
// acmart \subsection: \bfseries, no case change
var h2RPr = new StyleRunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman",
EastAsia = "SimSun",
ComplexScript = "Times New Roman"
},
new FontSize { Val = "20" }, // 10pt
new FontSizeComplexScript { Val = "20" },
new Color { Val = "000000" },
new Bold() // Bold, no caps
);
styles.Append(new Style(
new StyleName { Val = "heading 2" },
new BasedOn { Val = "Normal" },
new NextParagraphStyle { Val = "Normal" },
new UIPriority { Val = 9 },
new PrimaryStyle(),
new StyleParagraphProperties(
new KeepNext(),
new KeepLines(),
new SpacingBetweenLines { Before = "200", After = "80" },
new Indentation { FirstLine = "0" },
new OutlineLevel { Val = 1 }
),
h2RPr
)
{
Type = StyleValues.Paragraph,
StyleId = "Heading2",
Default = false
});
// ── Heading 3: 10pt bold italic, flush left ──
// acmart \subsubsection: \bfseries\itshape
var h3RPr = new StyleRunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman",
EastAsia = "SimSun",
ComplexScript = "Times New Roman"
},
new FontSize { Val = "20" }, // 10pt
new FontSizeComplexScript { Val = "20" },
new Color { Val = "000000" },
new Bold(),
new Italic() // Bold italic for H3
);
styles.Append(new Style(
new StyleName { Val = "heading 3" },
new BasedOn { Val = "Normal" },
new NextParagraphStyle { Val = "Normal" },
new UIPriority { Val = 9 },
new PrimaryStyle(),
new StyleParagraphProperties(
new KeepNext(),
new KeepLines(),
new SpacingBetweenLines { Before = "160", After = "60" },
new Indentation { FirstLine = "0" },
new OutlineLevel { Val = 2 }
),
h3RPr
)
{
Type = StyleValues.Paragraph,
StyleId = "Heading3",
Default = false
});
// ── Caption style: 8pt (sz=16) ──
styles.Append(CreateCaptionStyle(
fontSizeHalfPts: "16", // 8pt — ACM standard caption size
color: "000000",
italic: false
));
// ── References style: 7.5pt (sz=15) ──
var refsRPr = new StyleRunProperties(
new FontSize { Val = "15" }, // 7.5pt
new FontSizeComplexScript { Val = "15" }
);
styles.Append(new Style(
new StyleName { Val = "References" },
new BasedOn { Val = "Normal" },
new UIPriority { Val = 37 },
new PrimaryStyle(),
new StyleParagraphProperties(
new SpacingBetweenLines { After = "40" },
new Indentation { FirstLine = "0", Left = "360", Hanging = "360" }
),
refsRPr
)
{
Type = StyleValues.Paragraph,
StyleId = "References",
Default = false
});
// ── Page setup: US Letter, ACM margins, two-column ──
// acmart.cls: top=1.25in, bottom=1.25in, left=right=0.75in
var sectPr = new SectionProperties(
new WpPageSize { Width = 12240U, Height = 15840U }, // US Letter
new PageMargin
{
Top = 1800, // 1.25in
Bottom = 1800, // 1.25in
Left = 1080U, // 0.75in
Right = 1080U, // 0.75in
Header = 720U, Footer = 720U, Gutter = 0U
},
// Two-column layout: 0.33in gutter = 480 DXA
new WpColumns { ColumnCount = 2, Space = "480" }
);
// ── Page numbers: bottom center, 8pt ──
AddPageNumberFooter(mainPart, sectPr,
alignment: JustificationValues.Center,
fontSizeHalfPts: "16", // 8pt
color: "000000",
format: PageNumberFormat.Plain
);
// ── Sample content: ACM paper structure ──
// Title (flush left, bold)
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Title" }
),
new Run(new Text("Towards Scalable Graph Neural Networks for Heterogeneous Document Understanding"))
));
// Author block (flush left)
body.Append(new Paragraph(
new ParagraphProperties(
new SpacingBetweenLines { After = "60" },
new Indentation { FirstLine = "0" }
),
new Run(
new RunProperties(new FontSize { Val = "18" }, new FontSizeComplexScript { Val = "18" }),
new Text("Maria R. Garcia")
)
));
body.Append(new Paragraph(
new ParagraphProperties(
new SpacingBetweenLines { After = "60" },
new Indentation { FirstLine = "0" }
),
new Run(
new RunProperties(
new FontSize { Val = "16" }, new FontSizeComplexScript { Val = "16" },
new Italic()
),
new Text("Example University, City, Country")
)
));
body.Append(new Paragraph(
new ParagraphProperties(
new SpacingBetweenLines { After = "200" },
new Indentation { FirstLine = "0" }
),
new Run(
new RunProperties(
new FontSize { Val = "16" }, new FontSizeComplexScript { Val = "16" }
),
new Text("garcia@example.edu")
)
));
// Abstract section
body.Append(new Paragraph(
new ParagraphProperties(
new Indentation { FirstLine = "0" },
new SpacingBetweenLines { After = "80" }
),
new Run(
new RunProperties(
new Bold(),
new FontSize { Val = "18" }, new FontSizeComplexScript { Val = "18" }
),
new Text("ABSTRACT")
)
));
AddSampleParagraph(body, "Graph neural networks (GNNs) have emerged as a powerful tool for "
+ "document understanding tasks that require modeling relationships between document "
+ "elements. We present a scalable GNN architecture that processes heterogeneous "
+ "document graphs containing text, table, and figure nodes. Our approach achieves "
+ "competitive results while reducing computational costs by 40%.", "Normal");
// CCS Concepts / Keywords (ACM-specific metadata)
body.Append(new Paragraph(
new ParagraphProperties(
new Indentation { FirstLine = "0" },
new SpacingBetweenLines { Before = "120", After = "120" }
),
new Run(
new RunProperties(
new Bold(),
new FontSize { Val = "16" }, new FontSizeComplexScript { Val = "16" }
),
new Text("Keywords: ") { Space = SpaceProcessingModeValues.Preserve }
),
new Run(
new RunProperties(
new FontSize { Val = "16" }, new FontSizeComplexScript { Val = "16" }
),
new Text("graph neural networks, document understanding, scalability")
)
));
// 1 INTRODUCTION (arabic numbered, ALL CAPS via style)
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Heading1" }
),
new Run(new Text("1 Introduction"))
));
AddSampleParagraph(body, "Document understanding encompasses a broad set of tasks including "
+ "layout analysis, information extraction, and document classification. Recent work "
+ "has demonstrated that modeling the structural relationships between document "
+ "elements can significantly improve performance on these tasks.", "Normal");
AddSampleParagraph(body, "Graph neural networks provide a natural framework for representing "
+ "and reasoning about document structure. However, existing GNN-based approaches face "
+ "scalability challenges when processing large or complex documents.", "Normal");
// 2 RELATED WORK
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Heading1" }
),
new Run(new Text("2 Related Work"))
));
// 2.1 Subsection
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Heading2" }
),
new Run(new Text("2.1 Document Representation Learning"))
));
AddSampleParagraph(body, "Pre-trained language models have been adapted for document "
+ "understanding by incorporating layout information. LayoutLM and its successors "
+ "demonstrate the value of multi-modal pre-training for document tasks.", "Normal");
// 2.1.1 Sub-subsection
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Heading3" }
),
new Run(new Text("2.1.1 Multi-Modal Approaches"))
));
AddSampleParagraph(body, "Multi-modal approaches jointly model text, layout, and visual "
+ "features. This integration has proven critical for tasks where visual appearance "
+ "carries semantic meaning, such as form understanding.", "Normal");
// 3 METHOD
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Heading1" }
),
new Run(new Text("3 Proposed Method"))
));
AddSampleParagraph(body, "We propose HetDocGNN, a heterogeneous graph neural network "
+ "designed specifically for document understanding. The architecture operates on "
+ "a document graph where nodes represent text blocks, tables, and figures, and "
+ "edges encode spatial and logical relationships.", "Normal");
// Results table
body.Append(CreateThreeLineTable(
new[] { "Model", "DocVQA", "InfoVQA", "Params" },
new[]
{
new[] { "LayoutLMv3", "83.4", "45.1", "133M" },
new[] { "UDOP", "84.7", "47.4", "770M" },
new[] { "HetDocGNN", "85.2", "48.9", "89M" }
}
));
AddSampleParagraph(body, "Table 1: Comparison on document understanding benchmarks.", "Caption");
// 4 CONCLUSION
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Heading1" }
),
new Run(new Text("4 Conclusion"))
));
AddSampleParagraph(body, "We have presented HetDocGNN, a scalable graph neural network "
+ "for heterogeneous document understanding. Our approach achieves state-of-the-art "
+ "results with significantly fewer parameters than competing methods.", "Normal");
// REFERENCES section
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Heading1" }
),
new Run(new Text("References"))
));
// Sample references in ACM style (7.5pt)
AddSampleParagraph(body, "[1] Yiheng Xu, et al. 2020. LayoutLM: Pre-training of Text and "
+ "Layout for Document Image Understanding. In KDD '20. ACM, 1192\u20131200.", "References");
AddSampleParagraph(body, "[2] Zhiliang Peng, et al. 2023. UDOP: Unifying Vision, Text, "
+ "and Layout for Universal Document Processing. In CVPR '23. 19254\u201319264.", "References");
AddSampleParagraph(body, "[3] Zilong Wang, et al. 2022. DocFormer: End-to-End Transformer "
+ "for Document Understanding. In ICCV '22. 993\u20131003.", "References");
// Section properties must be last child of body
body.Append(sectPr);
}
}

View File

@@ -0,0 +1,999 @@
// ============================================================================
// AestheticRecipeSamples_Batch2.cs — Academic citation style recipes (APA 7, MLA 9)
// ============================================================================
// Recipes 8-9: Strict compliance with academic citation style guides.
// These are NOT aesthetic "design" choices — they are codified standards
// mandated by publishers, universities, and professional organizations.
//
// UNIT REFERENCE:
// Font size: half-points (22 = 11pt, 24 = 12pt, 32 = 16pt)
// Spacing: DXA = twentieths of a point (1440 DXA = 1 inch)
// Borders: eighth-points (4 = 0.5pt, 8 = 1pt, 12 = 1.5pt)
// Line spacing "line": 240ths of single spacing (240 = 1.0x, 480 = 2.0x)
// ============================================================================
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using WpPageSize = DocumentFormat.OpenXml.Wordprocessing.PageSize;
namespace MiniMaxAIDocx.Core.Samples;
public static partial class AestheticRecipeSamples
{
// ════════════════════════════════════════════════════════════════════════
// RECIPE 8: APA 7TH EDITION (PROFESSIONAL PAPER)
// ════════════════════════════════════════════════════════════════════════
/// <summary>
/// Recipe: APA 7th Edition — Professional Paper
/// Source: Publication Manual of the American Psychological Association,
/// 7th edition (2020), Chapters 2 (Paper Elements) and 6 (Mechanics of Style).
///
/// Key APA 7 specifications:
/// - Font: 12pt Times New Roman (Section 2.19). Also acceptable: 11pt Calibri,
/// 11pt Arial, 10pt Lucida Sans Unicode, or 11pt Georgia.
/// - Margins: 1 inch on all sides (Section 2.22).
/// - Line spacing: Double-spaced throughout, including title page and references (Section 2.21).
/// - Paragraph indent: 0.5 inch first-line indent for body paragraphs (Section 2.24).
/// - Heading levels (Section 2.27):
/// Level 1: Centered, Bold, Title Case Heading
/// Level 2: Flush Left, Bold, Title Case Heading
/// Level 3: Flush Left, Bold Italic, Title Case Heading
/// Level 4: Indented, Bold, Title Case Heading, Ending With a Period. (run-in)
/// Level 5: Indented, Bold Italic, Title Case Heading, Ending With a Period. (run-in)
/// All headings are 12pt — hierarchy through format, NOT size.
/// - Page numbers: top right corner on every page including title page (Section 2.18).
/// - Running head: flush left, ALL CAPS, for professional papers only (Section 2.18).
/// - Abstract: "Abstract" centered bold; single paragraph, not indented (Section 2.9).
/// - No numbered headings (APA does not use section numbers).
///
/// Design rationale:
/// - Every parameter is dictated by the style guide, not aesthetic preference.
/// - Double spacing with first-line indent (no paragraph spacing) is the
/// traditional academic convention — it provides annotation room and
/// clear paragraph boundaries without wasting vertical space.
/// - Uniform 12pt headings ensure the text content is primary; headings
/// serve as navigational aids, not visual statements.
/// </summary>
public static void CreateAPA7Document(string outputPath)
{
using var doc = WordprocessingDocument.Create(outputPath, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new Document(new Body());
var body = mainPart.Document.Body!;
// ── Styles ──
var stylesPart = mainPart.AddNewPart<StyleDefinitionsPart>();
stylesPart.Styles = new Styles();
var styles = stylesPart.Styles;
// DocDefaults: 12pt Times New Roman, double spacing, 0.5in first-line indent
// NOTE: 11pt Calibri and 11pt Arial are also acceptable per APA 7 Section 2.19
styles.Append(new DocDefaults(
new RunPropertiesDefault(
new RunPropertiesBaseStyle(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman",
EastAsia = "SimSun",
ComplexScript = "Times New Roman"
},
new FontSize { Val = "24" }, // 12pt (half-points)
new FontSizeComplexScript { Val = "24" },
new Color { Val = "000000" }, // Pure black
new Languages { Val = "en-US", EastAsia = "zh-CN" }
)
),
new ParagraphPropertiesDefault(
new ParagraphPropertiesBaseStyle(
new SpacingBetweenLines
{
// Double spacing throughout (APA 7, Section 2.21)
// 480 = 2.0x (240 = single spacing)
Line = "480",
LineRule = LineSpacingRuleValues.Auto,
After = "0" // No paragraph spacing — APA uses indent, not space
},
// First-line indent 0.5in = 720 DXA (APA 7, Section 2.24)
new Indentation { FirstLine = "720" }
)
)
));
// ── Normal style ──
styles.Append(CreateParagraphStyle(
styleId: "Normal",
styleName: "Normal",
isDefault: true,
uiPriority: 0
));
// ── APA Level 1: Centered, Bold, Title Case ──
// Same 12pt as body — hierarchy via format, NOT size (APA 7, Section 2.27)
styles.Append(CreateAcademicHeadingStyle(
level: 1,
sizeHalfPts: "24", // 12pt — same as body
bold: true,
italic: false,
centered: true,
spaceBefore: "480", // One double-spaced blank line before
spaceAfter: "0"
));
// ── APA Level 2: Flush Left, Bold, Title Case ──
styles.Append(CreateAcademicHeadingStyle(
level: 2,
sizeHalfPts: "24", // 12pt — same as body
bold: true,
italic: false,
centered: false,
spaceBefore: "480",
spaceAfter: "0"
));
// ── APA Level 3: Flush Left, Bold Italic, Title Case ──
styles.Append(CreateAcademicHeadingStyle(
level: 3,
sizeHalfPts: "24", // 12pt — same as body
bold: true,
italic: true,
centered: false,
spaceBefore: "480",
spaceAfter: "0"
));
// ── APA Level 4: Indented 0.5in, Bold, Title Case, Ending With Period. ──
// This is a "run-in" heading in APA — the heading text runs into the paragraph.
// In OpenXML we approximate by creating an indented bold paragraph.
styles.Append(CreateAPA7RunInHeadingStyle(
level: 4,
bold: true,
italic: false
));
// ── APA Level 5: Indented 0.5in, Bold Italic, Title Case, Ending With Period. ──
styles.Append(CreateAPA7RunInHeadingStyle(
level: 5,
bold: true,
italic: true
));
// ── "Abstract" label style: centered, bold, no indent ──
styles.Append(CreateAPA7NoIndentCenteredStyle(
styleId: "APAAbstractLabel",
styleName: "APA Abstract Label",
bold: true
));
// ── Abstract body style: no first-line indent ──
styles.Append(CreateAPA7NoIndentStyle(
styleId: "APAAbstractBody",
styleName: "APA Abstract Body"
));
// ── Title page style: centered, bold, no indent ──
styles.Append(CreateAPA7NoIndentCenteredStyle(
styleId: "APATitlePageTitle",
styleName: "APA Title Page Title",
bold: true
));
// ── Title page author/affiliation: centered, no indent, not bold ──
styles.Append(CreateAPA7NoIndentCenteredStyle(
styleId: "APATitlePageInfo",
styleName: "APA Title Page Info",
bold: false
));
// ── Page setup: US Letter, 1in all sides (APA 7, Section 2.22) ──
var sectPr = new SectionProperties(
new WpPageSize { Width = 12240U, Height = 15840U }, // 8.5" x 11"
new PageMargin
{
Top = 1440, Bottom = 1440,
Left = 1440U, Right = 1440U,
Header = 720U, Footer = 720U, Gutter = 0U
}
);
// ── Running head + page number in header ──
// Professional papers: running head flush left (ALL CAPS), page number flush right
// Both in the same header (APA 7, Section 2.18)
AddAPA7Header(mainPart, sectPr, "COGNITIVE EFFECTS OF SLEEP DEPRIVATION");
// ══════════════════════════════════════════════════════════════════
// SAMPLE CONTENT: Title Page, Abstract, Body with all 5 heading levels
// ══════════════════════════════════════════════════════════════════
// ── Title page ──
// Title: centered, bold, upper half of page (3-4 blank lines before)
AddAPA7TitlePage(body,
title: "Cognitive Effects of Sleep Deprivation on Working Memory Performance",
authorName: "Sarah J. Mitchell",
affiliation: "Department of Psychology, University of Washington",
courseLine: "PSY 401: Advanced Cognitive Psychology",
instructorLine: "Dr. Robert Chen",
dateLine: "October 15, 2024"
);
// ── Abstract page ──
AddSampleParagraph(body, "Abstract", "APAAbstractLabel");
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "APAAbstractBody" }
),
new Run(new Text(
"This study examined the effects of acute sleep deprivation on working memory "
+ "performance in college-aged adults. Participants (N = 48) were randomly assigned "
+ "to either a sleep deprivation condition (24 hours without sleep) or a control "
+ "condition (normal sleep). Working memory was assessed using a dual n-back task. "
+ "Results indicated that sleep-deprived participants showed significantly lower "
+ "accuracy (M = 72.3%, SD = 8.1) compared to controls (M = 89.7%, SD = 5.4), "
+ "t(46) = 9.12, p < .001, d = 2.52. These findings suggest that even a single "
+ "night of sleep deprivation substantially impairs working memory capacity."
))
));
// ── Body: Level 1 heading ──
AddSampleParagraph(body, "Cognitive Effects of Sleep Deprivation on Working Memory Performance", "Heading1");
AddSampleParagraph(body,
"Sleep deprivation is increasingly prevalent among college students, with approximately "
+ "50% reporting insufficient sleep on a regular basis (Hershner & Chervin, 2014). The "
+ "consequences of inadequate sleep extend beyond daytime drowsiness, affecting core "
+ "cognitive processes including attention, executive function, and working memory.",
"Normal");
// ── Level 2 heading ──
AddSampleParagraph(body, "Theoretical Framework", "Heading2");
AddSampleParagraph(body,
"Working memory, as conceptualized by Baddeley and Hitch (1974), comprises a central "
+ "executive system supported by the phonological loop and visuospatial sketchpad. Sleep "
+ "deprivation has been hypothesized to primarily affect the central executive component, "
+ "which governs attentional control and task coordination.",
"Normal");
// ── Level 3 heading ──
AddSampleParagraph(body, "Neural Mechanisms of Sleep-Related Cognitive Decline", "Heading3");
AddSampleParagraph(body,
"Neuroimaging studies have demonstrated that sleep deprivation is associated with "
+ "reduced activation in the prefrontal cortex, the neural substrate most closely linked "
+ "to working memory function (Chee & Chuah, 2007). Additionally, thalamic deactivation "
+ "may impair the relay of sensory information necessary for memory encoding.",
"Normal");
// ── Level 4 heading (run-in, bold, ends with period) ──
// APA Level 4 is a run-in heading: the heading text and paragraph text
// share the same line. We approximate with a bold indented paragraph.
body.Append(CreateAPA7RunInParagraph(
headingText: "Prefrontal Cortex Involvement.",
bodyText: " The dorsolateral prefrontal cortex (DLPFC) shows the greatest "
+ "susceptibility to sleep loss. Functional MRI studies reveal a dose-dependent "
+ "relationship between hours of wakefulness and DLPFC activation levels during "
+ "working memory tasks.",
bold: true,
italic: false
));
// ── Level 5 heading (run-in, bold italic, ends with period) ──
body.Append(CreateAPA7RunInParagraph(
headingText: "Glutamatergic Pathways.",
bodyText: " Recent research has identified glutamatergic signaling in the "
+ "prefrontal cortex as a key mediator of sleep deprivation effects on working "
+ "memory. Antagonism of NMDA receptors produces cognitive deficits similar to "
+ "those observed following 24 hours of sleep loss.",
bold: true,
italic: true
));
// ── Level 2: Method section ──
AddSampleParagraph(body, "Method", "Heading2");
AddSampleParagraph(body,
"This experiment used a between-subjects design with sleep condition (deprived vs. "
+ "control) as the independent variable and working memory accuracy as the dependent "
+ "variable. All procedures were approved by the University of Washington Institutional "
+ "Review Board (Protocol #2024-0847).",
"Normal");
// ── Level 2: Results ──
AddSampleParagraph(body, "Results", "Heading2");
AddSampleParagraph(body,
"An independent-samples t test revealed a statistically significant difference in "
+ "working memory accuracy between the sleep-deprived group (M = 72.3%, SD = 8.1) "
+ "and the control group (M = 89.7%, SD = 5.4), t(46) = 9.12, p < .001. The effect "
+ "size was large (Cohen's d = 2.52), indicating a substantial practical difference.",
"Normal");
// ── Level 2: Discussion ──
AddSampleParagraph(body, "Discussion", "Heading2");
AddSampleParagraph(body,
"The findings of this study are consistent with previous research demonstrating the "
+ "deleterious effects of sleep deprivation on cognitive performance. The magnitude of "
+ "the effect observed here exceeds that reported in meta-analytic reviews, possibly "
+ "due to the use of a more demanding dual n-back paradigm that places greater demands "
+ "on executive control processes.",
"Normal");
// Section properties must be last child of body
body.Append(sectPr);
}
/// <summary>
/// Creates an APA 7 "run-in" heading style (Levels 4 and 5).
/// These headings are indented 0.5in and end with a period;
/// the paragraph text runs in on the same line as the heading.
/// In OpenXML, we create a paragraph style with the appropriate formatting.
/// </summary>
private static Style CreateAPA7RunInHeadingStyle(int level, bool bold, bool italic)
{
var rPr = new StyleRunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman",
EastAsia = "SimSun",
ComplexScript = "Times New Roman"
},
new FontSize { Val = "24" }, // 12pt — same as body
new FontSizeComplexScript { Val = "24" },
new Color { Val = "000000" }
);
if (bold)
rPr.Append(new Bold());
if (italic)
rPr.Append(new Italic());
var pPr = new StyleParagraphProperties(
new KeepNext(),
new KeepLines(),
new SpacingBetweenLines
{
Before = "480",
After = "0",
Line = "480",
LineRule = LineSpacingRuleValues.Auto
},
// Indented 0.5in = 720 DXA (APA 7 Levels 4-5)
new Indentation { FirstLine = "720" },
new OutlineLevel { Val = level - 1 }
);
return new Style(
new StyleName { Val = $"heading {level}" },
new BasedOn { Val = "Normal" },
new NextParagraphStyle { Val = "Normal" },
new UIPriority { Val = 9 },
new PrimaryStyle(),
pPr,
rPr
)
{
Type = StyleValues.Paragraph,
StyleId = $"Heading{level}",
Default = false
};
}
/// <summary>
/// Creates a centered, optionally bold paragraph style with no first-line indent.
/// Used for APA title page elements and the "Abstract" label.
/// </summary>
private static Style CreateAPA7NoIndentCenteredStyle(string styleId, string styleName, bool bold)
{
var rPr = new StyleRunProperties(
new FontSize { Val = "24" },
new FontSizeComplexScript { Val = "24" }
);
if (bold)
rPr.Append(new Bold());
return new Style(
new StyleName { Val = styleName },
new BasedOn { Val = "Normal" },
new UIPriority { Val = 1 },
new StyleParagraphProperties(
new Justification { Val = JustificationValues.Center },
new Indentation { FirstLine = "0" },
new SpacingBetweenLines
{
Line = "480",
LineRule = LineSpacingRuleValues.Auto,
After = "0"
}
),
rPr
)
{
Type = StyleValues.Paragraph,
StyleId = styleId,
Default = false
};
}
/// <summary>
/// Creates a left-aligned paragraph style with no first-line indent.
/// Used for the abstract body text (APA 7 specifies no indent for abstract).
/// </summary>
private static Style CreateAPA7NoIndentStyle(string styleId, string styleName)
{
return new Style(
new StyleName { Val = styleName },
new BasedOn { Val = "Normal" },
new UIPriority { Val = 1 },
new StyleParagraphProperties(
new Indentation { FirstLine = "0" },
new SpacingBetweenLines
{
Line = "480",
LineRule = LineSpacingRuleValues.Auto,
After = "0"
}
)
)
{
Type = StyleValues.Paragraph,
StyleId = styleId,
Default = false
};
}
/// <summary>
/// Adds the APA 7 professional paper header: running head flush left (ALL CAPS)
/// and page number flush right, both in the same header line.
/// Per APA 7, Section 2.18: the running head appears on every page.
/// </summary>
private static void AddAPA7Header(MainDocumentPart mainPart, SectionProperties sectPr, string runningHeadText)
{
// Use a tab stop at the right margin to position the page number flush right
// Right margin position: page width (12240) - left margin (1440) - right margin (1440) = 9360 DXA
var headerParagraph = new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "Normal" },
new Indentation { FirstLine = "0" },
new SpacingBetweenLines { Line = "240", LineRule = LineSpacingRuleValues.Auto, After = "0" },
new Tabs(
new TabStop
{
Val = TabStopValues.Right,
Position = 9360 // Flush right at the text area edge
}
)
),
// Running head text (flush left, ALL CAPS)
new Run(
new RunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman"
},
new FontSize { Val = "24" },
new FontSizeComplexScript { Val = "24" }
),
new Text(runningHeadText) { Space = SpaceProcessingModeValues.Preserve }
),
// Tab to move to right-aligned position
new Run(
new RunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman"
},
new FontSize { Val = "24" },
new FontSizeComplexScript { Val = "24" }
),
new TabChar()
),
// Page number (flush right)
new SimpleField(
new Run(
new RunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman"
},
new FontSize { Val = "24" },
new FontSizeComplexScript { Val = "24" }
),
new Text("1")
)
)
{ Instruction = " PAGE " }
);
var headerPart = mainPart.AddNewPart<HeaderPart>();
headerPart.Header = new Header(headerParagraph);
headerPart.Header.Save();
string headerPartId = mainPart.GetIdOfPart(headerPart);
sectPr.Append(new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = headerPartId
});
}
/// <summary>
/// Adds the APA 7 title page content: title, author, affiliation,
/// course, instructor, and date — all centered and double-spaced.
/// Per APA 7, Section 2.3: title should be bold, centered, in upper half of page.
/// </summary>
private static void AddAPA7TitlePage(Body body,
string title, string authorName, string affiliation,
string courseLine, string instructorLine, string dateLine)
{
// Add some blank lines to position title in upper half of page
for (int i = 0; i < 3; i++)
{
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "APATitlePageInfo" }
)
));
}
// Title: centered, bold
AddSampleParagraph(body, title, "APATitlePageTitle");
// Author name
AddSampleParagraph(body, authorName, "APATitlePageInfo");
// Affiliation
AddSampleParagraph(body, affiliation, "APATitlePageInfo");
// Course
AddSampleParagraph(body, courseLine, "APATitlePageInfo");
// Instructor
AddSampleParagraph(body, instructorLine, "APATitlePageInfo");
// Date
AddSampleParagraph(body, dateLine, "APATitlePageInfo");
// Page break after title page
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "APATitlePageInfo" }
),
new Run(new Break { Type = BreakValues.Page })
));
}
/// <summary>
/// Creates an APA Level 4 or 5 "run-in" paragraph where the heading text
/// (bold or bold italic) is followed by the body text on the same line.
/// The heading ends with a period per APA 7 convention.
/// </summary>
private static Paragraph CreateAPA7RunInParagraph(
string headingText, string bodyText, bool bold, bool italic)
{
var headingRunProps = new RunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman",
ComplexScript = "Times New Roman"
},
new FontSize { Val = "24" },
new FontSizeComplexScript { Val = "24" }
);
if (bold)
headingRunProps.Append(new Bold());
if (italic)
headingRunProps.Append(new Italic());
return new Paragraph(
new ParagraphProperties(
new Indentation { FirstLine = "720" }, // 0.5in indent
new SpacingBetweenLines
{
Line = "480",
LineRule = LineSpacingRuleValues.Auto,
After = "0"
}
),
// Heading run (bold / bold italic)
new Run(
headingRunProps,
new Text(headingText) { Space = SpaceProcessingModeValues.Preserve }
),
// Body text run (regular)
new Run(
new RunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman",
ComplexScript = "Times New Roman"
},
new FontSize { Val = "24" },
new FontSizeComplexScript { Val = "24" }
),
new Text(bodyText) { Space = SpaceProcessingModeValues.Preserve }
)
);
}
// ════════════════════════════════════════════════════════════════════════
// RECIPE 9: MLA 9TH EDITION
// ════════════════════════════════════════════════════════════════════════
/// <summary>
/// Recipe: MLA 9th Edition
/// Source: MLA Handbook, 9th edition (2021), Part 1 (Principles of Scholarship)
/// and Part 2 (Details of MLA Style).
///
/// Key MLA 9 specifications:
/// - Font: 12pt Times New Roman (or other readable font; Times New Roman is standard).
/// - Margins: 1 inch on all sides.
/// - Line spacing: Double-spaced throughout, including block quotes and Works Cited.
/// - Paragraph indent: 0.5 inch first-line indent for body paragraphs.
/// - Title: Centered, same size as body text (12pt), NOT bold, italic, or underlined.
/// MLA eschews visual hierarchy — the title is distinguished only by centering.
/// - No mandatory heading system. If headings are used, they should be simple and
/// consistent. MLA does not prescribe heading levels like APA does.
/// - Running header: Author's last name and page number, flush right, 0.5 inch from top.
/// - First-page header block: Student's name, instructor's name, course title, and
/// date — upper left, double-spaced, NO extra spacing.
/// - Works Cited: title "Works Cited" centered (not bold), entries have hanging indent
/// of 0.5 inch (first line flush left, subsequent lines indented).
/// - No title page required (unless specifically requested by instructor).
///
/// Design rationale:
/// - MLA's aesthetic is deliberately plain — the writing is the content.
/// - No bold headings, no size variation, no decorative elements.
/// - The only structural markers are centering (title, Works Cited label)
/// and indentation (paragraphs, hanging indent for citations).
/// - This uniformity reflects MLA's roots in literary studies, where the
/// text itself is paramount and formatting should be invisible.
/// </summary>
public static void CreateMLA9Document(string outputPath)
{
using var doc = WordprocessingDocument.Create(outputPath, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new Document(new Body());
var body = mainPart.Document.Body!;
// ── Styles ──
var stylesPart = mainPart.AddNewPart<StyleDefinitionsPart>();
stylesPart.Styles = new Styles();
var styles = stylesPart.Styles;
// DocDefaults: 12pt Times New Roman, double spacing, 0.5in first-line indent
styles.Append(new DocDefaults(
new RunPropertiesDefault(
new RunPropertiesBaseStyle(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman",
EastAsia = "SimSun",
ComplexScript = "Times New Roman"
},
new FontSize { Val = "24" }, // 12pt
new FontSizeComplexScript { Val = "24" },
new Color { Val = "000000" },
new Languages { Val = "en-US", EastAsia = "zh-CN" }
)
),
new ParagraphPropertiesDefault(
new ParagraphPropertiesBaseStyle(
new SpacingBetweenLines
{
Line = "480", // Double spacing throughout
LineRule = LineSpacingRuleValues.Auto,
After = "0"
},
new Indentation { FirstLine = "720" } // 0.5in first-line indent
)
)
));
// ── Normal style ──
styles.Append(CreateParagraphStyle(
styleId: "Normal",
styleName: "Normal",
isDefault: true,
uiPriority: 0
));
// ── MLA Title style: centered, NOT bold/italic/underlined ──
// MLA is distinctive: the title has NO special formatting beyond centering.
styles.Append(CreateMLA9TitleStyle());
// ── MLA Header Block style: flush left, no indent ──
styles.Append(CreateMLA9HeaderBlockStyle());
// ── MLA Works Cited label style: centered, not bold ──
styles.Append(CreateMLA9WorksCitedLabelStyle());
// ── MLA Works Cited entry style: hanging indent 0.5in ──
styles.Append(CreateMLA9WorksCitedEntryStyle());
// ── Page setup: US Letter, 1in all sides ──
var sectPr = new SectionProperties(
new WpPageSize { Width = 12240U, Height = 15840U },
new PageMargin
{
Top = 1440, Bottom = 1440,
Left = 1440U, Right = 1440U,
Header = 720U, Footer = 720U, Gutter = 0U
}
);
// ── Running header: "LastName PageNumber" flush right ──
AddMLA9Header(mainPart, sectPr, "Mitchell");
// ══════════════════════════════════════════════════════════════════
// SAMPLE CONTENT: MLA header block, title, body, Works Cited
// ══════════════════════════════════════════════════════════════════
// ── First-page header block (upper left, double-spaced) ──
AddSampleParagraph(body, "Sarah Mitchell", "MLAHeaderBlock");
AddSampleParagraph(body, "Professor Johnson", "MLAHeaderBlock");
AddSampleParagraph(body, "English 201: American Literature", "MLAHeaderBlock");
AddSampleParagraph(body, "15 October 2024", "MLAHeaderBlock");
// ── Title: centered, 12pt, plain (not bold) ──
AddSampleParagraph(body, "The Function of the Unreliable Narrator in Nabokov's Lolita", "MLATitle");
// ── Body paragraphs ──
AddSampleParagraph(body,
"Vladimir Nabokov's Lolita (1955) remains one of the most studied examples of "
+ "unreliable narration in twentieth-century fiction. Humbert Humbert's elaborate, "
+ "self-justifying prose has been analyzed through numerous critical lenses, yet the "
+ "question of how the novel's narrative structure shapes reader complicity continues "
+ "to generate scholarly debate.",
"Normal");
AddSampleParagraph(body,
"The concept of the unreliable narrator, first articulated by Wayne C. Booth in "
+ "The Rhetoric of Fiction (1961), provides a foundational framework for understanding "
+ "Humbert's discourse. Booth argues that unreliable narrators are those whose values "
+ "diverge from those of the implied author (158-59). In Lolita, this divergence is "
+ "particularly complex because Nabokov layers multiple forms of unreliability: "
+ "factual, evaluative, and interpretive.",
"Normal");
AddSampleParagraph(body,
"Michael Wood has observed that \"Nabokov's genius lies in making us forget, "
+ "momentarily, that Humbert is a monster\" (127). This temporary forgetting is not "
+ "a failure of reading but a designed effect of the narrative voice. The luxurious "
+ "prose, the literary allusions, the self-deprecating wit \u2014 all serve to create what "
+ "Nomi Tamir-Ghez calls \"rhetorical seduction\" (42), in which readers find "
+ "themselves sympathizing with a narrator whose actions they would condemn.",
"Normal");
AddSampleParagraph(body,
"The structural implications of Humbert's unreliability extend beyond mere "
+ "factual distortion. As Eric Naiman demonstrates, the novel's famous opening "
+ "paragraph \u2014 with its incantatory repetition of \"Lolita\" \u2014 establishes a "
+ "pattern of linguistic possession that mirrors Humbert's physical possession of "
+ "Dolores Haze (85). The language itself becomes an instrument of control, one "
+ "that operates on the reader as well as on the characters within the narrative.",
"Normal");
// ── Works Cited ──
// Page break before Works Cited
body.Append(new Paragraph(
new ParagraphProperties(
new ParagraphStyleId { Val = "MLAHeaderBlock" }
),
new Run(new Break { Type = BreakValues.Page })
));
AddSampleParagraph(body, "Works Cited", "MLAWorksCitedLabel");
// Works Cited entries with hanging indent
AddSampleParagraph(body,
"Booth, Wayne C. The Rhetoric of Fiction. 2nd ed., U of Chicago P, 1983.",
"MLAWorksCitedEntry");
AddSampleParagraph(body,
"Nabokov, Vladimir. Lolita. 1955. Vintage International, 1989.",
"MLAWorksCitedEntry");
AddSampleParagraph(body,
"Naiman, Eric. Nabokov, Perversely. Cornell UP, 2010.",
"MLAWorksCitedEntry");
AddSampleParagraph(body,
"Tamir-Ghez, Nomi. \"The Art of Persuasion in Nabokov's Lolita.\" Poetics Today, "
+ "vol. 1, no. 1-2, 1979, pp. 65-83.",
"MLAWorksCitedEntry");
AddSampleParagraph(body,
"Wood, Michael. The Magician's Doubts: Nabokov and the Risks of Fiction. "
+ "Princeton UP, 1995.",
"MLAWorksCitedEntry");
// Section properties must be last child of body
body.Append(sectPr);
}
/// <summary>
/// MLA title style: centered, 12pt, NO bold/italic/underline.
/// MLA's radical plainness — the title is distinguished only by position.
/// </summary>
private static Style CreateMLA9TitleStyle()
{
return new Style(
new StyleName { Val = "MLA Title" },
new BasedOn { Val = "Normal" },
new UIPriority { Val = 1 },
new StyleParagraphProperties(
new Justification { Val = JustificationValues.Center },
new Indentation { FirstLine = "0" },
new SpacingBetweenLines
{
Line = "480",
LineRule = LineSpacingRuleValues.Auto,
After = "0"
}
)
)
{
Type = StyleValues.Paragraph,
StyleId = "MLATitle",
Default = false
};
}
/// <summary>
/// MLA first-page header block style: flush left, no first-line indent, double-spaced.
/// Used for the student name, instructor, course, and date lines.
/// </summary>
private static Style CreateMLA9HeaderBlockStyle()
{
return new Style(
new StyleName { Val = "MLA Header Block" },
new BasedOn { Val = "Normal" },
new UIPriority { Val = 1 },
new StyleParagraphProperties(
new Justification { Val = JustificationValues.Left },
new Indentation { FirstLine = "0" },
new SpacingBetweenLines
{
Line = "480",
LineRule = LineSpacingRuleValues.Auto,
After = "0"
}
)
)
{
Type = StyleValues.Paragraph,
StyleId = "MLAHeaderBlock",
Default = false
};
}
/// <summary>
/// MLA Works Cited label style: centered, 12pt, NOT bold.
/// Like the title, the label is plain — only centering distinguishes it.
/// </summary>
private static Style CreateMLA9WorksCitedLabelStyle()
{
return new Style(
new StyleName { Val = "MLA Works Cited Label" },
new BasedOn { Val = "Normal" },
new UIPriority { Val = 1 },
new StyleParagraphProperties(
new Justification { Val = JustificationValues.Center },
new Indentation { FirstLine = "0" },
new SpacingBetweenLines
{
Line = "480",
LineRule = LineSpacingRuleValues.Auto,
After = "0"
}
)
)
{
Type = StyleValues.Paragraph,
StyleId = "MLAWorksCitedLabel",
Default = false
};
}
/// <summary>
/// MLA Works Cited entry style: hanging indent of 0.5 inch (720 DXA).
/// First line is flush left; subsequent lines indent 0.5 inch.
/// This is the standard format for bibliography entries in MLA style.
/// </summary>
private static Style CreateMLA9WorksCitedEntryStyle()
{
return new Style(
new StyleName { Val = "MLA Works Cited Entry" },
new BasedOn { Val = "Normal" },
new UIPriority { Val = 1 },
new StyleParagraphProperties(
new Justification { Val = JustificationValues.Left },
// Hanging indent: Left = 720, FirstLine is negative (Hanging = 720)
new Indentation { Left = "720", Hanging = "720" },
new SpacingBetweenLines
{
Line = "480",
LineRule = LineSpacingRuleValues.Auto,
After = "0"
}
)
)
{
Type = StyleValues.Paragraph,
StyleId = "MLAWorksCitedEntry",
Default = false
};
}
/// <summary>
/// Adds the MLA 9 running header: author last name and page number, flush right,
/// 0.5 inch from top of page. Per MLA convention, this appears on every page.
/// </summary>
private static void AddMLA9Header(MainDocumentPart mainPart, SectionProperties sectPr, string authorLastName)
{
var headerParagraph = new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Right },
new Indentation { FirstLine = "0" },
new SpacingBetweenLines { Line = "240", LineRule = LineSpacingRuleValues.Auto, After = "0" }
),
// Author last name
new Run(
new RunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman"
},
new FontSize { Val = "24" },
new FontSizeComplexScript { Val = "24" }
),
new Text(authorLastName + " ") { Space = SpaceProcessingModeValues.Preserve }
),
// Page number
new SimpleField(
new Run(
new RunProperties(
new RunFonts
{
Ascii = "Times New Roman",
HighAnsi = "Times New Roman"
},
new FontSize { Val = "24" },
new FontSizeComplexScript { Val = "24" }
),
new Text("1")
)
)
{ Instruction = " PAGE " }
);
var headerPart = mainPart.AddNewPart<HeaderPart>();
headerPart.Header = new Header(headerParagraph);
headerPart.Header.Save();
string headerPartId = mainPart.GetIdOfPart(headerPart);
sectPr.Append(new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = headerPartId
});
}
}

View File

@@ -0,0 +1,624 @@
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace MiniMaxAIDocx.Core.Samples;
/// <summary>
/// Reference implementations for field codes and Table of Contents (TOC).
///
/// KEY CONCEPTS:
/// - SimpleField: single-element shorthand, e.g. &lt;w:fldSimple w:instr="PAGE"/&gt;
/// - Complex field: three FieldChar elements (Begin / Separate / End) with FieldCode between them.
/// Word always writes complex fields; SimpleField is only used for trivial cases.
/// - TOC is a structured document tag (SdtBlock) wrapping a complex field.
/// - UpdateFieldsOnOpen tells Word to recalculate all fields when opening.
/// </summary>
public static class FieldAndTocSamples
{
// ──────────────────────────────────────────────
// 1. InsertToc — TOC levels 1-3 inside SdtBlock
// ──────────────────────────────────────────────
/// <summary>
/// Inserts a Table of Contents covering heading levels 1-3.
/// Uses an SdtBlock wrapper with a complex field code:
/// TOC \o "1-3" \h \z \u
///
/// Switches:
/// \o "1-3" — outline levels 1-3
/// \h — hyperlinks
/// \z — hide tab leaders / page numbers in Web Layout
/// \u — use applied paragraph outline level
/// </summary>
public static SdtBlock InsertToc(Body body)
{
var sdtBlock = new SdtBlock();
// SdtProperties — mark as TOC
var sdtPr = new SdtProperties();
sdtPr.Append(new SdtContentDocPartObject(
new DocPartGallery { Val = "Table of Contents" },
new DocPartUnique()));
sdtBlock.Append(sdtPr);
// SdtContent — contains the field code paragraph(s)
var sdtContent = new SdtContentBlock();
// TOC title paragraph
var titlePara = new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "TOCHeading" }),
new Run(new Text("Table of Contents")));
sdtContent.Append(titlePara);
// Complex field paragraph for TOC
var fieldPara = new Paragraph();
InsertComplexFieldInline(fieldPara, " TOC \\o \"1-3\" \\h \\z \\u ");
sdtContent.Append(fieldPara);
sdtBlock.Append(sdtContent);
body.Append(sdtBlock);
return sdtBlock;
}
// ──────────────────────────────────────────────
// 2. InsertTocWithCustomLevels — TOC 1-4 levels
// ──────────────────────────────────────────────
/// <summary>
/// Inserts a TOC covering heading levels 1-4.
/// Identical structure to <see cref="InsertToc"/> but with "\o 1-4".
/// </summary>
public static SdtBlock InsertTocWithCustomLevels(Body body)
{
var sdtBlock = new SdtBlock();
var sdtPr = new SdtProperties();
sdtPr.Append(new SdtContentDocPartObject(
new DocPartGallery { Val = "Table of Contents" },
new DocPartUnique()));
sdtBlock.Append(sdtPr);
var sdtContent = new SdtContentBlock();
var titlePara = new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "TOCHeading" }),
new Run(new Text("Table of Contents")));
sdtContent.Append(titlePara);
// 1-4 levels instead of 1-3
var fieldPara = new Paragraph();
InsertComplexFieldInline(fieldPara, " TOC \\o \"1-4\" \\h \\z \\u ");
sdtContent.Append(fieldPara);
sdtBlock.Append(sdtContent);
body.Append(sdtBlock);
return sdtBlock;
}
// ──────────────────────────────────────────────
// 3. InsertSimpleField — PAGE, NUMPAGES, DATE, etc.
// ──────────────────────────────────────────────
/// <summary>
/// Inserts a SimpleField element into a paragraph.
///
/// SimpleField is the compact form: &lt;w:fldSimple w:instr=" PAGE "&gt;&lt;w:r&gt;...&lt;/w:r&gt;&lt;/w:fldSimple&gt;
///
/// Common instructions: "PAGE", "NUMPAGES", "DATE", "TIME", "FILENAME".
/// The run inside is the cached display value; Word recalculates on open.
/// </summary>
public static SimpleField InsertSimpleField(Paragraph para, string instruction)
{
var simpleField = new SimpleField { Instruction = $" {instruction} " };
// Cached display value — Word replaces this on recalculation
simpleField.Append(new Run(
new RunProperties(new NoProof()),
new Text("«" + instruction + "»")));
para.Append(simpleField);
return simpleField;
}
// ──────────────────────────────────────────────
// 4. InsertComplexField — Begin/Separate/End
// ──────────────────────────────────────────────
/// <summary>
/// Inserts a complex field into a paragraph using the FieldChar Begin/Separate/End pattern.
///
/// Structure:
/// Run1: FieldChar(Begin) + FieldCode(" PAGE ")
/// Run2: FieldChar(Separate)
/// Run3: Text("1") ← cached display value
/// Run4: FieldChar(End)
///
/// Use complex fields when you need dirty flags, lock, or nested fields.
/// </summary>
public static void InsertComplexField(Paragraph para, string instruction)
{
InsertComplexFieldInline(para, $" {instruction} ");
}
// ──────────────────────────────────────────────
// 5. InsertDateField — DATE with format switch
// ──────────────────────────────────────────────
/// <summary>
/// Inserts a DATE field with a format switch: DATE \@ "yyyy-MM-dd"
///
/// The \@ switch specifies the date/time picture.
/// Common formats:
/// \@ "yyyy-MM-dd" → 2026-03-22
/// \@ "MMMM d, yyyy" → March 22, 2026
/// \@ "M/d/yyyy h:mm am/pm" → 3/22/2026 2:30 PM
/// </summary>
public static void InsertDateField(Paragraph para, string format)
{
// Field instruction with date-time picture switch
string instruction = $" DATE \\@ \"{format}\" ";
InsertComplexFieldInline(para, instruction);
}
// ──────────────────────────────────────────────
// 6. InsertCrossReference — REF field
// ──────────────────────────────────────────────
/// <summary>
/// Inserts a REF cross-reference field that refers to a bookmark.
///
/// Instruction: REF bookmarkName \h
/// \h — creates a hyperlink to the bookmark
/// \p — inserts "above" or "below" relative position
/// \n — inserts paragraph number of the bookmark
/// </summary>
public static void InsertCrossReference(Paragraph para, string bookmarkName)
{
string instruction = $" REF {bookmarkName} \\h ";
InsertComplexFieldInline(para, instruction);
}
// ──────────────────────────────────────────────
// 7. InsertSequenceField — SEQ for numbering
// ──────────────────────────────────────────────
/// <summary>
/// Inserts a SEQ (sequence) field for auto-numbering figures, tables, etc.
///
/// Usage pattern for "Figure 1":
/// 1. Append a run with text "Figure " to the paragraph
/// 2. Call InsertSequenceField(para, "Figure")
///
/// Usage pattern for "Table 1":
/// 1. Append a run with text "Table " to the paragraph
/// 2. Call InsertSequenceField(para, "Table")
///
/// Each unique seqName maintains its own counter across the document.
/// </summary>
public static void InsertSequenceField(Paragraph para, string seqName)
{
string instruction = $" SEQ {seqName} \\* ARABIC ";
InsertComplexFieldInline(para, instruction);
}
// ──────────────────────────────────────────────
// 8. InsertMergeField — MERGEFIELD for mail merge
// ──────────────────────────────────────────────
/// <summary>
/// Inserts a MERGEFIELD for mail merge scenarios.
///
/// Instruction: MERGEFIELD fieldName \* MERGEFORMAT
/// \* MERGEFORMAT — preserves formatting applied to the field result
/// \b "text" — text before if field is non-empty
/// \f "text" — text after if field is non-empty
///
/// The cached display shows «fieldName» as a placeholder.
/// </summary>
public static void InsertMergeField(Paragraph para, string fieldName)
{
string instruction = $" MERGEFIELD {fieldName} \\* MERGEFORMAT ";
// Begin
para.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.Begin }));
// Field code
para.Append(new Run(
new FieldCode(instruction) { Space = SpaceProcessingModeValues.Preserve }));
// Separate
para.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.Separate }));
// Cached value — shows merge field placeholder
para.Append(new Run(
new RunProperties(new NoProof()),
new Text($"\u00AB{fieldName}\u00BB") { Space = SpaceProcessingModeValues.Preserve }));
// End
para.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.End }));
}
// ──────────────────────────────────────────────
// 9. InsertConditionalField — IF field
// ──────────────────────────────────────────────
/// <summary>
/// Inserts an IF conditional field.
///
/// Syntax: IF expression1 operator expression2 "true-text" "false-text"
/// Example: IF { MERGEFIELD Gender } = "Male" "Mr." "Ms."
///
/// This example checks if MERGEFIELD Amount > 1000 and displays different text.
/// Nested fields (MERGEFIELD inside IF) require nested Begin/End pairs.
/// </summary>
public static void InsertConditionalField(Paragraph para)
{
// Outer IF field Begin
para.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.Begin }));
para.Append(new Run(
new FieldCode(" IF ") { Space = SpaceProcessingModeValues.Preserve }));
// Nested MERGEFIELD inside the IF condition
para.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.Begin }));
para.Append(new Run(
new FieldCode(" MERGEFIELD Amount ") { Space = SpaceProcessingModeValues.Preserve }));
para.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.Separate }));
para.Append(new Run(
new Text("0") { Space = SpaceProcessingModeValues.Preserve }));
para.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.End }));
// Continuation of IF instruction
para.Append(new Run(
new FieldCode(" > \"1000\" \"High Value\" \"Standard\" ") { Space = SpaceProcessingModeValues.Preserve }));
// Separate — cached result
para.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.Separate }));
para.Append(new Run(
new RunProperties(new NoProof()),
new Text("Standard") { Space = SpaceProcessingModeValues.Preserve }));
// End
para.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.End }));
}
// ──────────────────────────────────────────────
// 10. InsertStyleRef — STYLEREF for running headers
// ──────────────────────────────────────────────
/// <summary>
/// Inserts a STYLEREF field, commonly used in headers/footers
/// to display the current chapter or section title.
///
/// Instruction: STYLEREF "Heading 1"
/// Displays the text of the nearest paragraph with style "Heading 1".
/// \l — search from bottom of page up (for last instance on page)
/// \n — insert the paragraph number, not text
/// </summary>
public static void InsertStyleRef(Paragraph para)
{
string instruction = " STYLEREF \"Heading 1\" ";
InsertComplexFieldInline(para, instruction);
}
// ──────────────────────────────────────────────
// 11. EnableUpdateFieldsOnOpen
// ──────────────────────────────────────────────
/// <summary>
/// Sets the UpdateFieldsOnOpen property so Word recalculates
/// all fields (PAGE, TOC, SEQ, etc.) when the document is opened.
///
/// Without this, TOC and cross-references show stale cached values
/// until the user manually presses Ctrl+A, F9 to update.
/// </summary>
public static void EnableUpdateFieldsOnOpen(DocumentSettingsPart settingsPart)
{
settingsPart.Settings ??= new Settings();
var existing = settingsPart.Settings.GetFirstChild<UpdateFieldsOnOpen>();
if (existing != null)
{
existing.Val = true;
}
else
{
settingsPart.Settings.Append(new UpdateFieldsOnOpen { Val = true });
}
settingsPart.Settings.Save();
}
// ──────────────────────────────────────────────
// 12. CreateTocStyles — TOC1/2/3 with tab leaders
// ──────────────────────────────────────────────
/// <summary>
/// Creates TOC1, TOC2, TOC3 paragraph styles with right-aligned tab stops
/// and dot leaders (the "....." between entry text and page number).
///
/// Each TOC level is indented further:
/// TOC1 — 0 indent
/// TOC2 — 240 twips (1/6 inch)
/// TOC3 — 480 twips (1/3 inch)
///
/// Tab leader: dot-filled right tab at 9360 twips (6.5 inches for letter paper).
/// </summary>
public static void CreateTocStyles(StyleDefinitionsPart stylesPart)
{
stylesPart.Styles ??= new Styles();
string[] tocStyleIds = ["TOC1", "TOC2", "TOC3"];
string[] tocStyleNames = ["toc 1", "toc 2", "toc 3"];
int[] indents = [0, 240, 480]; // twips
// Right tab position: 6.5 inches = 9360 twips (standard for US Letter)
const int tabPosition = 9360;
for (int i = 0; i < tocStyleIds.Length; i++)
{
var style = new Style
{
Type = StyleValues.Paragraph,
StyleId = tocStyleIds[i],
CustomStyle = false
};
style.Append(new StyleName { Val = tocStyleNames[i] });
style.Append(new BasedOn { Val = "Normal" });
style.Append(new NextParagraphStyle { Val = "Normal" });
style.Append(new UIPriority { Val = 39 });
var pPr = new StyleParagraphProperties();
// Indentation for nested levels
if (indents[i] > 0)
{
pPr.Append(new Indentation { Left = indents[i].ToString() });
}
// Spacing: no space after for compact TOC
pPr.Append(new SpacingBetweenLines { After = "0", Line = "276", LineRule = LineSpacingRuleValues.Auto });
// Right-aligned tab with dot leader
var tabs = new Tabs();
tabs.Append(new TabStop
{
Val = TabStopValues.Right,
Leader = TabStopLeaderCharValues.Dot,
Position = tabPosition
});
pPr.Append(tabs);
style.Append(pPr);
stylesPart.Styles.Append(style);
}
stylesPart.Styles.Save();
}
// ──────────────────────────────────────────────
// 13. CreateMixedTocStructure — Real-world TOC
// ──────────────────────────────────────────────
/// <summary>
/// Real-world TOC structure: Mixed SDT block + static entries + field code.
///
/// IMPORTANT: Most templates do NOT have a clean TOC field code alone.
/// Instead, they contain:
/// 1. An SDT (Structured Document Tag) wrapper with alias "TOC"
/// 2. Inside the SDT: a field code BEGIN + SEPARATE + static example entries + END
/// 3. The static entries are placeholder text (e.g., "第1章 绪论...........1")
/// that Word replaces when user presses "Update Fields"
///
/// When applying a template (Scenario C), you should:
/// - KEEP the entire SDT block from the template (don't rebuild it)
/// - DO NOT replace static entries with programmatic content
/// - The entries will auto-update when the user opens in Word and updates fields
/// - If you must update entries programmatically, replace the content INSIDE
/// the SDT between fldChar separate and fldChar end
///
/// Common mistake: Treating TOC as pure field code and rebuilding it from scratch,
/// which destroys the SDT wrapper and breaks Word's "Update Table" functionality.
/// </summary>
public static void CreateMixedTocStructure(string outputPath)
{
using var doc = WordprocessingDocument.Create(outputPath, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new Document();
var body = new Body();
mainPart.Document.Append(body);
// Add styles part with TOC styles
var stylesPart = mainPart.AddNewPart<StyleDefinitionsPart>();
CreateTocStyles(stylesPart);
// ─── SDT Block wrapping the entire TOC ───
var sdtBlock = new SdtBlock();
// SDT Properties: alias "TOC", tag, and DocPartGallery
var sdtPr = new SdtProperties();
sdtPr.Append(new SdtAlias { Val = "TOC" });
sdtPr.Append(new Tag { Val = "TOC" });
sdtPr.Append(new SdtContentDocPartObject(
new DocPartGallery { Val = "Table of Contents" },
new DocPartUnique()));
sdtBlock.Append(sdtPr);
// SDT Content: field code + static entries
var sdtContent = new SdtContentBlock();
// ─── TOC title paragraph ───
var titlePara = new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "TOCHeading" }),
new Run(new Text("目 录")));
sdtContent.Append(titlePara);
// ─── Field code BEGIN paragraph ───
var fieldBeginPara = new Paragraph();
// fldChar Begin
fieldBeginPara.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.Begin }));
// instrText: TOC \o "1-3" \h \z \u
fieldBeginPara.Append(new Run(
new FieldCode(" TOC \\o \"1-3\" \\h \\z \\u ") { Space = SpaceProcessingModeValues.Preserve }));
// fldChar Separate
fieldBeginPara.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.Separate }));
sdtContent.Append(fieldBeginPara);
// ─── Static placeholder entries (TOC1/TOC2/TOC3) ───
// These are the example entries that Word will replace when user clicks "Update Table".
// In real templates, these show example chapter titles with dot leaders and page numbers.
// TOC level 1 entry: "第1章 绪论...........1"
sdtContent.Append(CreateStaticTocEntry("TOC1", "第1章 绪论", "1"));
// TOC level 2 entry: "1.1 研究背景...........1"
sdtContent.Append(CreateStaticTocEntry("TOC2", "1.1 研究背景", "1"));
// TOC level 2 entry: "1.2 研究目的...........2"
sdtContent.Append(CreateStaticTocEntry("TOC2", "1.2 研究目的", "2"));
// TOC level 1 entry: "第2章 文献综述...........3"
sdtContent.Append(CreateStaticTocEntry("TOC1", "第2章 文献综述", "3"));
// TOC level 2 entry: "2.1 国内研究现状...........3"
sdtContent.Append(CreateStaticTocEntry("TOC2", "2.1 国内研究现状", "3"));
// TOC level 3 entry: "2.1.1 早期研究...........4"
sdtContent.Append(CreateStaticTocEntry("TOC3", "2.1.1 早期研究", "4"));
// TOC level 1 entry: "第3章 研究方法...........5"
sdtContent.Append(CreateStaticTocEntry("TOC1", "第3章 研究方法", "5"));
// ─── Field code END paragraph ───
var fieldEndPara = new Paragraph();
fieldEndPara.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.End }));
sdtContent.Append(fieldEndPara);
sdtBlock.Append(sdtContent);
body.Append(sdtBlock);
// ─── Actual heading paragraphs (what the TOC references) ───
body.Append(new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "Heading1" }),
new Run(new Text("第1章 绪论"))));
body.Append(new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "Heading2" }),
new Run(new Text("1.1 研究背景"))));
body.Append(new Paragraph(
new Run(new Text("本研究旨在探讨……"))));
body.Append(new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "Heading2" }),
new Run(new Text("1.2 研究目的"))));
body.Append(new Paragraph(
new Run(new Text("研究目的包括……"))));
body.Append(new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "Heading1" }),
new Run(new Text("第2章 文献综述"))));
body.Append(new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "Heading2" }),
new Run(new Text("2.1 国内研究现状"))));
body.Append(new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "Heading3" }),
new Run(new Text("2.1.1 早期研究"))));
body.Append(new Paragraph(
new Run(new Text("早期研究表明……"))));
body.Append(new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "Heading1" }),
new Run(new Text("第3章 研究方法"))));
body.Append(new Paragraph(
new Run(new Text("本章介绍研究方法……"))));
// ─── Enable UpdateFieldsOnOpen so TOC auto-refreshes ───
var settingsPart = mainPart.AddNewPart<DocumentSettingsPart>();
EnableUpdateFieldsOnOpen(settingsPart);
mainPart.Document.Save();
}
/// <summary>
/// Helper: creates a single static TOC entry paragraph with style, text, tab leader, and page number.
/// This mirrors what Word generates inside a TOC SDT block.
/// </summary>
private static Paragraph CreateStaticTocEntry(string tocStyleId, string entryText, string pageNumber)
{
var para = new Paragraph();
// Paragraph properties: TOC style + right-aligned tab with dot leader
var pPr = new ParagraphProperties();
pPr.Append(new ParagraphStyleId { Val = tocStyleId });
para.Append(pPr);
// Run with entry text
para.Append(new Run(
new RunProperties(new NoProof()),
new Text(entryText) { Space = SpaceProcessingModeValues.Preserve }));
// Tab character (creates the dot leader between text and page number)
para.Append(new Run(new TabChar()));
// Page number
para.Append(new Run(
new RunProperties(new NoProof()),
new Text(pageNumber)));
return para;
}
// ──────────────────────────────────────────────
// Private helper: insert complex field inline
// ──────────────────────────────────────────────
/// <summary>
/// Shared helper that appends Begin / FieldCode / Separate / CachedValue / End
/// runs to a paragraph.
/// </summary>
private static void InsertComplexFieldInline(Paragraph para, string instruction)
{
// Run 1: FieldChar Begin
para.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.Begin }));
// Run 2: FieldCode (the instruction text)
para.Append(new Run(
new FieldCode(instruction) { Space = SpaceProcessingModeValues.Preserve }));
// Run 3: FieldChar Separate
para.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.Separate }));
// Run 4: Cached display value (placeholder until Word recalculates)
para.Append(new Run(
new RunProperties(new NoProof()),
new Text("1") { Space = SpaceProcessingModeValues.Preserve }));
// Run 5: FieldChar End
para.Append(new Run(
new FieldChar { FieldCharType = FieldCharValues.End }));
}
}

View File

@@ -0,0 +1,675 @@
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
// W15 types for people.xml (Office 2013+ comment author tracking)
using W15Person = DocumentFormat.OpenXml.Office2013.Word.Person;
using W15People = DocumentFormat.OpenXml.Office2013.Word.People;
using W15PresenceInfo = DocumentFormat.OpenXml.Office2013.Word.PresenceInfo;
namespace MiniMaxAIDocx.Core.Samples;
/// <summary>
/// Reference implementations for footnotes, endnotes, comments, bookmarks, and hyperlinks.
///
/// KEY CONCEPTS:
/// - FootnotesPart must contain separator (id=-1) and continuationSeparator (id=0) footnotes.
/// - Comments require up to 4 parts: comments.xml, commentsExtended.xml, commentsIds.xml, people.xml.
/// - CommentRangeStart/CommentRangeEnd wrap the commented text; CommentReference goes in a run after CommentRangeEnd.
/// - Bookmarks use BookmarkStart/BookmarkEnd pairs with matching Id attributes.
/// - External hyperlinks require a HyperlinkRelationship in the part's relationships.
/// </summary>
public static class FootnoteAndCommentSamples
{
// ──────────────────────────────────────────────
// 1. SetupFootnotesPart — required separator footnotes
// ──────────────────────────────────────────────
/// <summary>
/// Initializes the FootnotesPart with the two REQUIRED special footnotes:
/// - id=-1: separator (the short horizontal line between body text and footnotes)
/// - id=0: continuationSeparator (line shown when a footnote spans pages)
///
/// Word will refuse to render footnotes correctly without these.
/// Call this once before adding any footnotes.
/// </summary>
public static FootnotesPart SetupFootnotesPart(MainDocumentPart mainPart)
{
var footnotesPart = mainPart.FootnotesPart
?? mainPart.AddNewPart<FootnotesPart>();
footnotesPart.Footnotes = new Footnotes();
// Separator footnote (id = -1): renders as a short horizontal rule
var separator = new Footnote { Type = FootnoteEndnoteValues.Separator, Id = -1 };
separator.Append(new Paragraph(
new ParagraphProperties(new SpacingBetweenLines { After = "0", Line = "240", LineRule = LineSpacingRuleValues.Auto }),
new Run(new SeparatorMark())));
footnotesPart.Footnotes.Append(separator);
// Continuation separator footnote (id = 0): renders as a full-width rule
var contSeparator = new Footnote { Type = FootnoteEndnoteValues.ContinuationSeparator, Id = 0 };
contSeparator.Append(new Paragraph(
new ParagraphProperties(new SpacingBetweenLines { After = "0", Line = "240", LineRule = LineSpacingRuleValues.Auto }),
new Run(new ContinuationSeparatorMark())));
footnotesPart.Footnotes.Append(contSeparator);
footnotesPart.Footnotes.Save();
return footnotesPart;
}
// ──────────────────────────────────────────────
// 2. AddFootnote — reference in body + content in part
// ──────────────────────────────────────────────
/// <summary>
/// Adds a footnote with two coordinated pieces:
/// 1. A FootnoteReference in the body paragraph (superscript number in the text)
/// 2. A Footnote element in the FootnotesPart (the actual footnote content)
///
/// The footnote id links the two together. IDs must be unique and > 0
/// (ids -1 and 0 are reserved for separator and continuationSeparator).
/// </summary>
public static int AddFootnote(MainDocumentPart mainPart, Paragraph para, string footnoteText)
{
// Ensure footnotes part exists with separators
if (mainPart.FootnotesPart == null)
{
SetupFootnotesPart(mainPart);
}
int footnoteId = GetNextFootnoteId(mainPart.FootnotesPart!);
// 1. Add the footnote reference in the body paragraph
// This renders the superscript number (e.g., "1") in the text
var refRun = new Run(
new RunProperties(new VerticalTextAlignment { Val = VerticalPositionValues.Superscript }),
new FootnoteReference { Id = footnoteId });
para.Append(refRun);
// 2. Add the footnote content in the FootnotesPart
var footnote = new Footnote { Id = footnoteId };
// Footnote paragraph starts with a self-referencing FootnoteReference
var footnotePara = new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "FootnoteText" }),
new Run(
new RunProperties(new VerticalTextAlignment { Val = VerticalPositionValues.Superscript }),
new FootnoteReferenceMark()),
new Run(
new Text(" " + footnoteText) { Space = SpaceProcessingModeValues.Preserve }));
footnote.Append(footnotePara);
mainPart.FootnotesPart!.Footnotes!.Append(footnote);
mainPart.FootnotesPart.Footnotes.Save();
return footnoteId;
}
// ──────────────────────────────────────────────
// 3. AddEndnote — same pattern for endnotes
// ──────────────────────────────────────────────
/// <summary>
/// Adds an endnote. Same two-part pattern as footnotes:
/// 1. EndnoteReference in body paragraph
/// 2. Endnote element in EndnotesPart
///
/// EndnotesPart also requires separator (id=-1) and continuationSeparator (id=0).
/// Endnotes appear at the end of the document (or section) rather than page bottom.
/// </summary>
public static int AddEndnote(MainDocumentPart mainPart, Paragraph para, string endnoteText)
{
// Ensure endnotes part exists with separators
if (mainPart.EndnotesPart == null)
{
SetupEndnotesPart(mainPart);
}
int endnoteId = GetNextEndnoteId(mainPart.EndnotesPart!);
// 1. Endnote reference in body text
var refRun = new Run(
new RunProperties(new VerticalTextAlignment { Val = VerticalPositionValues.Superscript }),
new EndnoteReference { Id = endnoteId });
para.Append(refRun);
// 2. Endnote content in EndnotesPart
var endnote = new Endnote { Id = endnoteId };
var endnotePara = new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "EndnoteText" }),
new Run(
new RunProperties(new VerticalTextAlignment { Val = VerticalPositionValues.Superscript }),
new EndnoteReferenceMark()),
new Run(
new Text(" " + endnoteText) { Space = SpaceProcessingModeValues.Preserve }));
endnote.Append(endnotePara);
mainPart.EndnotesPart!.Endnotes!.Append(endnote);
mainPart.EndnotesPart.Endnotes.Save();
return endnoteId;
}
// ──────────────────────────────────────────────
// 4. SetFootnoteProperties — position, numbering restart
// ──────────────────────────────────────────────
/// <summary>
/// Configures footnote properties on a section:
/// - Position: page bottom (default) vs. beneath text
/// - Numbering format: decimal, lowerRoman, symbol, etc.
/// - Numbering restart: continuous, eachSection, eachPage
///
/// These go inside SectionProperties as w:footnotePr.
/// </summary>
public static void SetFootnoteProperties(SectionProperties sectPr)
{
var footnotePr = new FootnoteProperties();
// Position: PageBottom is default; BeneathText puts them right after text
footnotePr.Append(new FootnotePosition { Val = FootnotePositionValues.PageBottom });
// Numbering format: decimal (1, 2, 3...)
footnotePr.Append(new NumberingFormat { Val = NumberFormatValues.Decimal });
// Restart numbering each section (alternatives: Continuous, EachPage)
footnotePr.Append(new NumberingRestart { Val = RestartNumberValues.EachSection });
// Starting number
footnotePr.Append(new NumberingStart { Val = 1 });
sectPr.Append(footnotePr);
}
// ──────────────────────────────────────────────
// 5. SetupCommentSystem — all 4 parts
// ──────────────────────────────────────────────
/// <summary>
/// Initializes the complete comment system with all required parts:
/// 1. WordprocessingCommentsPart — comments.xml (the Comment elements)
/// 2. WordprocessingCommentsExPart — commentsExtended.xml (reply threading, done state)
/// 3. WordprocessingCommentsIdsPart — commentsIds.xml (durable GUID-based comment IDs)
/// 4. WordprocessingPeoplePart — people.xml (author identities)
///
/// All four parts must be present and consistent for modern Word to
/// display comments correctly without repair prompts.
/// </summary>
public static void SetupCommentSystem(MainDocumentPart mainPart)
{
// Part 1: comments.xml
if (mainPart.WordprocessingCommentsPart == null)
{
var commentsPart = mainPart.AddNewPart<WordprocessingCommentsPart>();
commentsPart.Comments = new Comments();
commentsPart.Comments.Save();
}
// Part 2: commentsExtended.xml — for reply threading and done/resolved state
// Uses W15 namespace (word/2012/wordml)
if (mainPart.WordprocessingCommentsExPart == null)
{
var commentsExPart = mainPart.AddNewPart<WordprocessingCommentsExPart>();
// Initialize with root element via raw XML since the typed API is limited
using var writer = new System.IO.StreamWriter(commentsExPart.GetStream(System.IO.FileMode.Create));
writer.Write("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"
+ "<w15:commentsEx xmlns:w15=\"http://schemas.microsoft.com/office/word/2012/wordml\""
+ " xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\""
+ " mc:Ignorable=\"w15\"/>");
}
// Part 3: commentsIds.xml — durable comment identifiers (W16CID namespace)
if (mainPart.WordprocessingCommentsIdsPart == null)
{
var commentsIdsPart = mainPart.AddNewPart<WordprocessingCommentsIdsPart>();
using var writer = new System.IO.StreamWriter(commentsIdsPart.GetStream(System.IO.FileMode.Create));
writer.Write("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"
+ "<w16cid:commentsIds xmlns:w16cid=\"http://schemas.microsoft.com/office/word/2016/wordml/cid\"/>");
}
// Part 4: people.xml — author info for comments
if (mainPart.WordprocessingPeoplePart == null)
{
var peoplePart = mainPart.AddNewPart<WordprocessingPeoplePart>();
peoplePart.People = new W15People();
peoplePart.People.Save();
}
}
// ──────────────────────────────────────────────
// 6. AddComment — full comment with range markers
// ──────────────────────────────────────────────
/// <summary>
/// Adds a comment anchored to an entire paragraph with three coordinated elements:
///
/// In the document body (inside the paragraph):
/// 1. CommentRangeStart { Id = commentId } — before commented content
/// 2. CommentRangeEnd { Id = commentId } — after commented content
/// 3. Run containing CommentReference { Id = commentId } — immediately after RangeEnd
///
/// In comments.xml:
/// 4. Comment { Id = commentId } with paragraph content
///
/// The CommentReference run is what makes the comment indicator appear in the margin.
/// </summary>
public static int AddComment(MainDocumentPart mainPart, Paragraph para, string author, string text)
{
SetupCommentSystem(mainPart);
var commentsPart = mainPart.WordprocessingCommentsPart!;
int commentId = GetNextCommentId(commentsPart);
string idStr = commentId.ToString();
// Add comment range markers to the paragraph
// Insert CommentRangeStart before existing content
para.InsertAt(new CommentRangeStart { Id = idStr }, 0);
// Append CommentRangeEnd + CommentReference after content
para.Append(new CommentRangeEnd { Id = idStr });
para.Append(new Run(
new RunProperties(
new RunStyle { Val = "CommentReference" }),
new CommentReference { Id = idStr }));
// Create the comment content in comments.xml
var comment = new Comment
{
Id = idStr,
Author = author,
Date = DateTime.UtcNow,
Initials = GetInitials(author)
};
comment.Append(new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "CommentText" }),
new Run(
new RunProperties(new RunStyle { Val = "CommentReference" }),
new AnnotationReferenceMark()),
new Run(new Text(text) { Space = SpaceProcessingModeValues.Preserve })));
commentsPart.Comments!.Append(comment);
commentsPart.Comments.Save();
// Register author in people.xml
EnsurePersonEntry(mainPart, author);
return commentId;
}
// ──────────────────────────────────────────────
// 7. AddCommentReply — reply via commentsExtended
// ──────────────────────────────────────────────
/// <summary>
/// Adds a reply to an existing comment. Replies are threaded via commentsExtended.xml
/// which links the reply's paraId to the parent comment's paraId using w15:paraIdParent.
///
/// The reply is a separate Comment element in comments.xml (with its own unique id),
/// but it does NOT get CommentRangeStart/End markers in the document body.
/// The threading relationship is purely in commentsExtended.xml.
/// </summary>
public static int AddCommentReply(MainDocumentPart mainPart, int parentCommentId, string author, string replyText)
{
SetupCommentSystem(mainPart);
var commentsPart = mainPart.WordprocessingCommentsPart!;
int replyId = GetNextCommentId(commentsPart);
string replyIdStr = replyId.ToString();
// Generate a unique paraId for the reply paragraph (w14:paraId)
string replyParaId = GenerateParaId();
// Create reply as a Comment in comments.xml
var reply = new Comment
{
Id = replyIdStr,
Author = author,
Date = DateTime.UtcNow,
Initials = GetInitials(author)
};
var replyPara = new Paragraph(
new ParagraphProperties(new ParagraphStyleId { Val = "CommentText" }),
new Run(new Text(replyText) { Space = SpaceProcessingModeValues.Preserve }));
// Set paraId on the paragraph via extended attributes (W14 namespace)
replyPara.SetAttribute(new OpenXmlAttribute("w14", "paraId", "http://schemas.microsoft.com/office/word/2010/wordml", replyParaId));
reply.Append(replyPara);
commentsPart.Comments!.Append(reply);
commentsPart.Comments.Save();
// Link the reply to the parent in commentsExtended.xml
// Find the parent comment's paraId, then create a commentEx element
var parentComment = commentsPart.Comments.Elements<Comment>()
.FirstOrDefault(c => c.Id?.Value == parentCommentId.ToString());
string parentParaId = "00000000";
if (parentComment != null)
{
var firstPara = parentComment.GetFirstChild<Paragraph>();
if (firstPara != null)
{
var attr = firstPara.GetAttributes().FirstOrDefault(a => a.LocalName == "paraId");
if (attr.Value != null) parentParaId = attr.Value;
}
}
// Write commentEx entry to commentsExtended.xml
// This links replyParaId -> parentParaId
if (mainPart.WordprocessingCommentsExPart != null)
{
var stream = mainPart.WordprocessingCommentsExPart.GetStream(System.IO.FileMode.Open);
var doc = System.Xml.Linq.XDocument.Load(stream);
stream.Dispose();
System.Xml.Linq.XNamespace w15 = "http://schemas.microsoft.com/office/word/2012/wordml";
doc.Root!.Add(new System.Xml.Linq.XElement(w15 + "commentEx",
new System.Xml.Linq.XAttribute(w15 + "paraId", replyParaId),
new System.Xml.Linq.XAttribute(w15 + "paraIdParent", parentParaId)));
using var writeStream = mainPart.WordprocessingCommentsExPart.GetStream(System.IO.FileMode.Create);
doc.Save(writeStream);
}
EnsurePersonEntry(mainPart, author);
return replyId;
}
// ──────────────────────────────────────────────
// 8. DeleteComment — remove from all parts + markers
// ──────────────────────────────────────────────
/// <summary>
/// Completely removes a comment from the document by cleaning up all four locations:
/// 1. CommentRangeStart/End from document body
/// 2. CommentReference run from document body
/// 3. Comment element from comments.xml
/// 4. CommentEx entry from commentsExtended.xml
///
/// Failing to remove from all locations causes Word to show repair prompts.
/// </summary>
public static void DeleteComment(MainDocumentPart mainPart, int commentId)
{
string idStr = commentId.ToString();
// 1. Remove markers from document body
var body = mainPart.Document?.Body;
if (body != null)
{
// Remove all CommentRangeStart with matching id
foreach (var start in body.Descendants<CommentRangeStart>()
.Where(s => s.Id?.Value == idStr).ToList())
{
start.Remove();
}
// Remove all CommentRangeEnd with matching id
foreach (var end in body.Descendants<CommentRangeEnd>()
.Where(e => e.Id?.Value == idStr).ToList())
{
end.Remove();
}
// Remove runs containing CommentReference with matching id
foreach (var reference in body.Descendants<CommentReference>()
.Where(r => r.Id?.Value == idStr).ToList())
{
// Remove the parent Run, not just the CommentReference
reference.Parent?.Remove();
}
}
// 2. Remove from comments.xml
var commentsPart = mainPart.WordprocessingCommentsPart;
if (commentsPart?.Comments != null)
{
var comment = commentsPart.Comments.Elements<Comment>()
.FirstOrDefault(c => c.Id?.Value == idStr);
comment?.Remove();
commentsPart.Comments.Save();
}
// 3. Remove from commentsExtended.xml (reply threading)
if (mainPart.WordprocessingCommentsExPart != null)
{
var stream = mainPart.WordprocessingCommentsExPart.GetStream(System.IO.FileMode.Open);
var doc = System.Xml.Linq.XDocument.Load(stream);
stream.Dispose();
System.Xml.Linq.XNamespace w15 = "http://schemas.microsoft.com/office/word/2012/wordml";
// Find and remove commentEx entries that reference this comment's paraId
// We need to find the paraId from the comment first, but since we already removed it,
// we remove by matching — in practice you would track paraIds before deletion
var toRemove = doc.Root!.Elements(w15 + "commentEx").ToList();
// Remove entries whose paraId matches any paragraph in the deleted comment
foreach (var elem in toRemove)
{
// In a full implementation, match by paraId correlation
// For safety, this removes entries that are no longer referenced
_ = elem; // kept for reference
}
using var writeStream = mainPart.WordprocessingCommentsExPart.GetStream(System.IO.FileMode.Create);
doc.Save(writeStream);
}
// 4. Remove from commentsIds.xml if present
if (mainPart.WordprocessingCommentsIdsPart != null)
{
var stream = mainPart.WordprocessingCommentsIdsPart.GetStream(System.IO.FileMode.Open);
var doc = System.Xml.Linq.XDocument.Load(stream);
stream.Dispose();
System.Xml.Linq.XNamespace w16cid = "http://schemas.microsoft.com/office/word/2016/wordml/cid";
var toRemove = doc.Root!.Elements(w16cid + "commentId")
.Where(e => (string?)e.Attribute(w16cid + "paraId") == idStr)
.ToList();
foreach (var elem in toRemove)
{
elem.Remove();
}
using var writeStream = mainPart.WordprocessingCommentsIdsPart.GetStream(System.IO.FileMode.Create);
doc.Save(writeStream);
}
}
// ──────────────────────────────────────────────
// 9. AddBookmark — BookmarkStart + BookmarkEnd
// ──────────────────────────────────────────────
/// <summary>
/// Adds a bookmark spanning the entire paragraph content.
///
/// Structure:
/// &lt;w:bookmarkStart w:id="1" w:name="my_bookmark"/&gt;
/// ... paragraph content ...
/// &lt;w:bookmarkEnd w:id="1"/&gt;
///
/// The id must be unique across all bookmarks in the document.
/// The name is used to reference the bookmark in REF fields and hyperlinks.
/// Bookmark names are case-insensitive and cannot contain spaces.
/// </summary>
public static void AddBookmark(Paragraph para, string bookmarkName, int bookmarkId)
{
string idStr = bookmarkId.ToString();
// Insert BookmarkStart at the beginning of the paragraph
para.InsertAt(new BookmarkStart { Id = idStr, Name = bookmarkName }, 0);
// Append BookmarkEnd at the end of the paragraph
para.Append(new BookmarkEnd { Id = idStr });
}
// ──────────────────────────────────────────────
// 10. AddInternalHyperlink — Hyperlink with Anchor
// ──────────────────────────────────────────────
/// <summary>
/// Adds a hyperlink that jumps to a bookmark within the same document.
///
/// Uses the Anchor property (NOT a relationship) to reference the bookmark name.
/// The run inside the Hyperlink should have "Hyperlink" character style for blue underline.
///
/// Structure:
/// &lt;w:hyperlink w:anchor="bookmarkName"&gt;
/// &lt;w:r&gt;&lt;w:rPr&gt;&lt;w:rStyle w:val="Hyperlink"/&gt;&lt;/w:rPr&gt;&lt;w:t&gt;Click here&lt;/w:t&gt;&lt;/w:r&gt;
/// &lt;/w:hyperlink&gt;
/// </summary>
public static Hyperlink AddInternalHyperlink(Paragraph para, string bookmarkName)
{
var hyperlink = new Hyperlink { Anchor = bookmarkName };
hyperlink.Append(new Run(
new RunProperties(
new RunStyle { Val = "Hyperlink" },
new Color { Val = "0563C1", ThemeColor = ThemeColorValues.Hyperlink }),
new Text(bookmarkName) { Space = SpaceProcessingModeValues.Preserve }));
para.Append(hyperlink);
return hyperlink;
}
// ──────────────────────────────────────────────
// 11. AddExternalHyperlink — Hyperlink with relationship
// ──────────────────────────────────────────────
/// <summary>
/// Adds a hyperlink to an external URL.
///
/// Unlike internal hyperlinks, external ones require a HyperlinkRelationship
/// in the part's .rels file. The Hyperlink element references the relationship Id.
///
/// Steps:
/// 1. Create a HyperlinkRelationship with the URL (isExternal: true)
/// 2. Create a Hyperlink element with Id = relationship Id
/// 3. Style the run with "Hyperlink" character style
/// </summary>
public static Hyperlink AddExternalHyperlink(MainDocumentPart mainPart, Paragraph para, string url, string displayText)
{
// Step 1: Create the relationship (external = true)
var relationship = mainPart.AddHyperlinkRelationship(new Uri(url, UriKind.Absolute), isExternal: true);
// Step 2: Create the Hyperlink element referencing the relationship
var hyperlink = new Hyperlink { Id = relationship.Id };
// Step 3: Styled run inside the hyperlink
hyperlink.Append(new Run(
new RunProperties(
new RunStyle { Val = "Hyperlink" },
new Color { Val = "0563C1", ThemeColor = ThemeColorValues.Hyperlink },
new Underline { Val = UnderlineValues.Single }),
new Text(displayText) { Space = SpaceProcessingModeValues.Preserve }));
para.Append(hyperlink);
return hyperlink;
}
// ──────────────────────────────────────────────
// Private helpers
// ──────────────────────────────────────────────
private static EndnotesPart SetupEndnotesPart(MainDocumentPart mainPart)
{
var endnotesPart = mainPart.EndnotesPart
?? mainPart.AddNewPart<EndnotesPart>();
endnotesPart.Endnotes = new Endnotes();
var separator = new Endnote { Type = FootnoteEndnoteValues.Separator, Id = -1 };
separator.Append(new Paragraph(
new ParagraphProperties(new SpacingBetweenLines { After = "0", Line = "240", LineRule = LineSpacingRuleValues.Auto }),
new Run(new SeparatorMark())));
endnotesPart.Endnotes.Append(separator);
var contSeparator = new Endnote { Type = FootnoteEndnoteValues.ContinuationSeparator, Id = 0 };
contSeparator.Append(new Paragraph(
new ParagraphProperties(new SpacingBetweenLines { After = "0", Line = "240", LineRule = LineSpacingRuleValues.Auto }),
new Run(new ContinuationSeparatorMark())));
endnotesPart.Endnotes.Append(contSeparator);
endnotesPart.Endnotes.Save();
return endnotesPart;
}
private static int GetNextFootnoteId(FootnotesPart footnotesPart)
{
int maxId = 0;
if (footnotesPart.Footnotes != null)
{
foreach (var fn in footnotesPart.Footnotes.Elements<Footnote>())
{
if (fn.Id?.Value != null && fn.Id.Value > maxId)
maxId = (int)fn.Id.Value;
}
}
return maxId + 1;
}
private static int GetNextEndnoteId(EndnotesPart endnotesPart)
{
int maxId = 0;
if (endnotesPart.Endnotes != null)
{
foreach (var en in endnotesPart.Endnotes.Elements<Endnote>())
{
if (en.Id?.Value != null && en.Id.Value > maxId)
maxId = (int)en.Id.Value;
}
}
return maxId + 1;
}
private static int GetNextCommentId(WordprocessingCommentsPart commentsPart)
{
int maxId = 0;
if (commentsPart.Comments != null)
{
foreach (var c in commentsPart.Comments.Elements<Comment>())
{
if (c.Id?.Value != null && int.TryParse(c.Id.Value, out int id) && id > maxId)
maxId = id;
}
}
return maxId + 1;
}
private static string GetInitials(string author)
{
if (string.IsNullOrWhiteSpace(author)) return "A";
var parts = author.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return string.Concat(parts.Select(p => p[..1].ToUpperInvariant()));
}
private static string GenerateParaId()
{
// paraId is an 8-character hex string (32-bit unsigned integer)
return Random.Shared.Next(0x10000000, int.MaxValue).ToString("X8");
}
private static void EnsurePersonEntry(MainDocumentPart mainPart, string author)
{
var peoplePart = mainPart.WordprocessingPeoplePart;
if (peoplePart?.People == null) return;
// Check if this author already has an entry
bool exists = peoplePart.People.Elements<W15Person>()
.Any(p => p.Author?.Value == author);
if (!exists)
{
var person = new W15Person { Author = author };
// PresenceInfo — the provider/userId for the author's identity
person.Append(new W15PresenceInfo
{
ProviderId = "None",
UserId = author
});
peoplePart.People.Append(person);
peoplePart.People.Save();
}
}
}

View File

@@ -0,0 +1,838 @@
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using A = DocumentFormat.OpenXml.Drawing;
using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing;
using PIC = DocumentFormat.OpenXml.Drawing.Pictures;
namespace MiniMaxAIDocx.Core.Samples;
/// <summary>
/// Comprehensive reference for OpenXML headers, footers, and page numbers.
///
/// Architecture:
/// - Headers/footers live in separate HeaderPart/FooterPart containers.
/// - They are linked to sections via HeaderReference/FooterReference in SectionProperties.
/// - Each reference has a Type: Default, First, Even.
/// - The relationship ID (r:id) connects the reference to the part.
///
/// XML structure in SectionProperties:
/// <w:sectPr>
/// <w:headerReference w:type="default" r:id="rId7"/>
/// <w:footerReference w:type="default" r:id="rId8"/>
/// <w:headerReference w:type="first" r:id="rId9"/>
/// <w:titlePg/> <!-- needed to activate first-page header/footer -->
/// </w:sectPr>
///
/// Header/Footer XML (in separate part):
/// <w:hdr> (or <w:ftr>)
/// <w:p>
/// <w:pPr>...</w:pPr>
/// <w:r><w:t>Header text</w:t></w:r>
/// </w:p>
/// </w:hdr>
///
/// Page number fields use complex field codes:
/// PAGE — current page number
/// NUMPAGES — total page count
/// </summary>
public static class HeaderFooterSamples
{
// ──────────────────────────────────────────────────────────────
// 1. AddSimpleHeader — basic text header
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Adds a simple text header to the default header slot.
///
/// Steps:
/// 1. Create a HeaderPart on the MainDocumentPart
/// 2. Set its Header content (must contain at least one Paragraph)
/// 3. Get the relationship ID
/// 4. Add HeaderReference to SectionProperties with type="default"
///
/// XML in header part:
/// <w:hdr>
/// <w:p>
/// <w:pPr><w:jc w:val="right"/></w:pPr>
/// <w:r>
/// <w:rPr><w:color w:val="808080"/><w:sz w:val="18"/></w:rPr>
/// <w:t>My Document Header</w:t>
/// </w:r>
/// </w:p>
/// </w:hdr>
///
/// XML in sectPr:
/// <w:headerReference w:type="default" r:id="rIdXX"/>
/// </summary>
public static void AddSimpleHeader(MainDocumentPart mainPart, SectionProperties sectPr, string text)
{
var headerPart = mainPart.AddNewPart<HeaderPart>();
headerPart.Header = new Header(
new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Right }),
new Run(
new RunProperties(
new Color { Val = "808080" },
new FontSize { Val = "18" }), // 9pt (half-points)
new Text(text) { Space = SpaceProcessingModeValues.Preserve })));
headerPart.Header.Save();
var headerRefId = mainPart.GetIdOfPart(headerPart);
sectPr.Append(new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = headerRefId
});
}
// ──────────────────────────────────────────────────────────────
// 2. AddSimpleFooter — basic text footer
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Adds a simple text footer to the default footer slot.
///
/// XML in footer part:
/// <w:ftr>
/// <w:p>
/// <w:pPr><w:jc w:val="center"/></w:pPr>
/// <w:r><w:t>Confidential</w:t></w:r>
/// </w:p>
/// </w:ftr>
///
/// XML in sectPr:
/// <w:footerReference w:type="default" r:id="rIdXX"/>
/// </summary>
public static void AddSimpleFooter(MainDocumentPart mainPart, SectionProperties sectPr, string text)
{
var footerPart = mainPart.AddNewPart<FooterPart>();
footerPart.Footer = new Footer(
new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Center }),
new Run(
new RunProperties(
new Color { Val = "808080" },
new FontSize { Val = "18" }),
new Text(text) { Space = SpaceProcessingModeValues.Preserve })));
footerPart.Footer.Save();
var footerRefId = mainPart.GetIdOfPart(footerPart);
sectPr.Append(new FooterReference
{
Type = HeaderFooterValues.Default,
Id = footerRefId
});
}
// ──────────────────────────────────────────────────────────────
// 3. AddPageNumberFooter — centered page number
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Adds a centered page number footer using the PAGE field code.
///
/// Field code pattern (3 runs):
/// Run 1: FieldChar Begin
/// Run 2: FieldCode " PAGE "
/// Run 3: FieldChar End
///
/// XML:
/// <w:ftr>
/// <w:p>
/// <w:pPr><w:jc w:val="center"/></w:pPr>
/// <w:r><w:fldChar w:fldCharType="begin"/></w:r>
/// <w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>
/// <w:r><w:fldChar w:fldCharType="end"/></w:r>
/// </w:p>
/// </w:ftr>
///
/// GOTCHA: FieldCode text MUST have leading/trailing spaces: " PAGE ", not "PAGE".
/// GOTCHA: Use Space = SpaceProcessingModeValues.Preserve on FieldCode to keep spaces.
/// </summary>
public static void AddPageNumberFooter(MainDocumentPart mainPart, SectionProperties sectPr)
{
var footerPart = mainPart.AddNewPart<FooterPart>();
var paragraph = new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Center }));
// PAGE field: Begin → InstrText → End
paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin }));
paragraph.Append(new Run(new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve }));
paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End }));
footerPart.Footer = new Footer(paragraph);
footerPart.Footer.Save();
var footerRefId = mainPart.GetIdOfPart(footerPart);
sectPr.Append(new FooterReference
{
Type = HeaderFooterValues.Default,
Id = footerRefId
});
}
// ──────────────────────────────────────────────────────────────
// 4. AddPageXofYFooter — "Page X of Y"
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Adds a footer with "Page X of Y" format using PAGE and NUMPAGES field codes.
///
/// XML:
/// <w:ftr>
/// <w:p>
/// <w:pPr><w:jc w:val="center"/></w:pPr>
/// <w:r><w:t xml:space="preserve">Page </w:t></w:r>
/// <w:r><w:fldChar w:fldCharType="begin"/></w:r>
/// <w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>
/// <w:r><w:fldChar w:fldCharType="end"/></w:r>
/// <w:r><w:t xml:space="preserve"> of </w:t></w:r>
/// <w:r><w:fldChar w:fldCharType="begin"/></w:r>
/// <w:r><w:instrText xml:space="preserve"> NUMPAGES </w:instrText></w:r>
/// <w:r><w:fldChar w:fldCharType="end"/></w:r>
/// </w:p>
/// </w:ftr>
/// </summary>
public static void AddPageXofYFooter(MainDocumentPart mainPart, SectionProperties sectPr)
{
var footerPart = mainPart.AddNewPart<FooterPart>();
var paragraph = new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Center }));
// "Page "
paragraph.Append(new Run(new Text("Page ") { Space = SpaceProcessingModeValues.Preserve }));
// PAGE field
paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin }));
paragraph.Append(new Run(new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve }));
paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End }));
// " of "
paragraph.Append(new Run(new Text(" of ") { Space = SpaceProcessingModeValues.Preserve }));
// NUMPAGES field
paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin }));
paragraph.Append(new Run(new FieldCode(" NUMPAGES ") { Space = SpaceProcessingModeValues.Preserve }));
paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End }));
footerPart.Footer = new Footer(paragraph);
footerPart.Footer.Save();
var footerRefId = mainPart.GetIdOfPart(footerPart);
sectPr.Append(new FooterReference
{
Type = HeaderFooterValues.Default,
Id = footerRefId
});
}
// ──────────────────────────────────────────────────────────────
// 5. AddDifferentFirstPageHeader — TitlePage element
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Adds a different header for the first page vs. subsequent pages.
///
/// Requires:
/// 1. <w:titlePg/> in SectionProperties to enable first-page header/footer
/// 2. HeaderReference with Type="first" for the first page header
/// 3. HeaderReference with Type="default" for subsequent pages
///
/// XML in sectPr:
/// <w:sectPr>
/// <w:headerReference w:type="first" r:id="rIdFirst"/>
/// <w:headerReference w:type="default" r:id="rIdDefault"/>
/// <w:titlePg/> <!-- CRITICAL: without this, first-page header is ignored -->
/// </w:sectPr>
///
/// GOTCHA: Without <w:titlePg/>, the "first" type header is completely ignored.
/// GOTCHA: If you want a blank first-page header, you still need a HeaderPart
/// with an empty Paragraph — just don't add text to it.
/// </summary>
public static void AddDifferentFirstPageHeader(MainDocumentPart mainPart, SectionProperties sectPr)
{
// First page header: e.g., cover page with large title
var firstHeaderPart = mainPart.AddNewPart<HeaderPart>();
firstHeaderPart.Header = new Header(
new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Center }),
new Run(
new RunProperties(
new Bold(),
new FontSize { Val = "32" }), // 16pt
new Text("COMPANY CONFIDENTIAL"))));
firstHeaderPart.Header.Save();
// Default header for subsequent pages
var defaultHeaderPart = mainPart.AddNewPart<HeaderPart>();
defaultHeaderPart.Header = new Header(
new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Right }),
new Run(
new RunProperties(
new FontSize { Val = "18" }), // 9pt
new Text("Internal Document"))));
defaultHeaderPart.Header.Save();
// Link both headers to section
sectPr.Append(new HeaderReference
{
Type = HeaderFooterValues.First,
Id = mainPart.GetIdOfPart(firstHeaderPart)
});
sectPr.Append(new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = mainPart.GetIdOfPart(defaultHeaderPart)
});
// CRITICAL: Enable first page header/footer
sectPr.Append(new TitlePage());
}
// ──────────────────────────────────────────────────────────────
// 6. AddEvenOddHeaders — EvenAndOddHeaders in Settings
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Creates different headers for even and odd pages (e.g., for book-style printing).
///
/// Requires:
/// 1. <w:evenAndOddHeaders/> in document Settings (DocumentSettingsPart)
/// 2. HeaderReference with Type="default" for odd pages
/// 3. HeaderReference with Type="even" for even pages
///
/// XML in settings.xml:
/// <w:settings>
/// <w:evenAndOddHeaders/>
/// </w:settings>
///
/// XML in sectPr:
/// <w:sectPr>
/// <w:headerReference w:type="default" r:id="rIdOdd"/>
/// <w:headerReference w:type="even" r:id="rIdEven"/>
/// </w:sectPr>
///
/// GOTCHA: "default" means ODD pages when evenAndOddHeaders is enabled.
/// GOTCHA: Without the Settings flag, the "even" header is ignored entirely.
/// </summary>
public static void AddEvenOddHeaders(MainDocumentPart mainPart, SectionProperties sectPr)
{
// Enable even/odd header distinction in document settings
var settingsPart = mainPart.DocumentSettingsPart
?? mainPart.AddNewPart<DocumentSettingsPart>();
if (settingsPart.Settings == null)
settingsPart.Settings = new Settings();
// Add EvenAndOddHeaders if not already present
if (settingsPart.Settings.GetFirstChild<EvenAndOddHeaders>() == null)
{
settingsPart.Settings.Append(new EvenAndOddHeaders());
}
settingsPart.Settings.Save();
// Odd page header (Type="default" means odd when even/odd is enabled)
var oddHeaderPart = mainPart.AddNewPart<HeaderPart>();
oddHeaderPart.Header = new Header(
new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Right }),
new Run(new Text("Chapter Title — Odd Page"))));
oddHeaderPart.Header.Save();
// Even page header
var evenHeaderPart = mainPart.AddNewPart<HeaderPart>();
evenHeaderPart.Header = new Header(
new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Left }),
new Run(new Text("Book Title — Even Page"))));
evenHeaderPart.Header.Save();
// Link to section
sectPr.Append(new HeaderReference
{
Type = HeaderFooterValues.Default, // = odd pages
Id = mainPart.GetIdOfPart(oddHeaderPart)
});
sectPr.Append(new HeaderReference
{
Type = HeaderFooterValues.Even,
Id = mainPart.GetIdOfPart(evenHeaderPart)
});
}
// ──────────────────────────────────────────────────────────────
// 7. AddHeaderWithLogo — image in header
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Adds a header containing an image (logo).
///
/// Steps:
/// 1. Create HeaderPart
/// 2. Add ImagePart to the HeaderPart (NOT to MainDocumentPart)
/// 3. Feed the image stream
/// 4. Build Drawing element with inline image
/// 5. Link HeaderPart to sectPr
///
/// Image sizing uses EMU (English Metric Units):
/// 914400 EMU = 1 inch
/// 360000 EMU = 1 cm
///
/// XML for inline image:
/// <w:drawing>
/// <wp:inline distT="0" distB="0" distL="0" distR="0">
/// <wp:extent cx="914400" cy="457200"/>
/// <wp:docPr id="1" name="Logo"/>
/// <a:graphic>
/// <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
/// <pic:pic>
/// <pic:nvPicPr>...</pic:nvPicPr>
/// <pic:blipFill><a:blip r:embed="rIdImg"/></pic:blipFill>
/// <pic:spPr>...</pic:spPr>
/// </pic:pic>
/// </a:graphicData>
/// </a:graphic>
/// </wp:inline>
/// </w:drawing>
///
/// GOTCHA: The ImagePart must be added to the HeaderPart, not the MainDocumentPart.
/// If you add it to MainDocumentPart, the relationship ID won't resolve in the header.
/// </summary>
public static void AddHeaderWithLogo(MainDocumentPart mainPart, SectionProperties sectPr, string imagePath)
{
var headerPart = mainPart.AddNewPart<HeaderPart>();
// Add image part to the HEADER part (not main document part)
var imagePart = headerPart.AddImagePart(ImagePartType.Png);
using (var stream = new FileStream(imagePath, FileMode.Open, FileAccess.Read))
{
imagePart.FeedData(stream);
}
var imageRelId = headerPart.GetIdOfPart(imagePart);
// Image dimensions in EMU: 1 inch wide x 0.5 inch tall
long widthEmu = 914400; // 1 inch
long heightEmu = 457200; // 0.5 inch
// Build the Drawing element with inline image
var drawing = new Drawing(
new DW.Inline(
new DW.Extent { Cx = widthEmu, Cy = heightEmu },
new DW.EffectExtent { LeftEdge = 0, TopEdge = 0, RightEdge = 0, BottomEdge = 0 },
new DW.DocProperties { Id = 1U, Name = "Logo" },
new A.Graphic(
new A.GraphicData(
new PIC.Picture(
new PIC.NonVisualPictureProperties(
new PIC.NonVisualDrawingProperties { Id = 0U, Name = "logo.png" },
new PIC.NonVisualPictureDrawingProperties()),
new PIC.BlipFill(
new A.Blip { Embed = imageRelId },
new A.Stretch(new A.FillRectangle())),
new PIC.ShapeProperties(
new A.Transform2D(
new A.Offset { X = 0, Y = 0 },
new A.Extents { Cx = widthEmu, Cy = heightEmu }),
new A.PresetGeometry(
new A.AdjustValueList())
{ Preset = A.ShapeTypeValues.Rectangle }))
) { Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" })
)
{
DistanceFromTop = 0U,
DistanceFromBottom = 0U,
DistanceFromLeft = 0U,
DistanceFromRight = 0U
});
headerPart.Header = new Header(
new Paragraph(new Run(drawing)));
headerPart.Header.Save();
var headerRefId = mainPart.GetIdOfPart(headerPart);
sectPr.Append(new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = headerRefId
});
}
// ──────────────────────────────────────────────────────────────
// 8. AddTableLayoutHeader — 3-column invisible table
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Creates a header with a 3-column invisible table for precise layout:
/// Left cell: Logo placeholder text
/// Center cell: Document title (centered)
/// Right cell: Page number (right-aligned)
///
/// The table has no borders, so it's invisible but provides column alignment.
///
/// XML structure:
/// <w:hdr>
/// <w:tbl>
/// <w:tblPr>
/// <w:tblW w:w="5000" w:type="pct"/>
/// <w:tblBorders>
/// <w:top w:val="none"/> <w:left w:val="none"/> ...
/// </w:tblBorders>
/// </w:tblPr>
/// <w:tblGrid>
/// <w:gridCol w:w="3120"/> <w:gridCol w:w="3120"/> <w:gridCol w:w="3120"/>
/// </w:tblGrid>
/// <w:tr>
/// <w:tc> <!-- left: logo text --> </w:tc>
/// <w:tc> <!-- center: title --> </w:tc>
/// <w:tc> <!-- right: page num --> </w:tc>
/// </w:tr>
/// </w:tbl>
/// </w:hdr>
/// </summary>
public static void AddTableLayoutHeader(MainDocumentPart mainPart, SectionProperties sectPr)
{
var headerPart = mainPart.AddNewPart<HeaderPart>();
// Invisible table (no borders)
var table = new Table();
var tblPr = new TableProperties(
new TableWidth { Width = "5000", Type = TableWidthUnitValues.Pct },
new TableBorders(
new TopBorder { Val = BorderValues.None, Size = 0, Space = 0, Color = "auto" },
new LeftBorder { Val = BorderValues.None, Size = 0, Space = 0, Color = "auto" },
new BottomBorder { Val = BorderValues.None, Size = 0, Space = 0, Color = "auto" },
new RightBorder { Val = BorderValues.None, Size = 0, Space = 0, Color = "auto" },
new InsideHorizontalBorder { Val = BorderValues.None, Size = 0, Space = 0, Color = "auto" },
new InsideVerticalBorder { Val = BorderValues.None, Size = 0, Space = 0, Color = "auto" }
),
// Fixed layout so columns don't shift
new TableLayout { Type = TableLayoutValues.Fixed });
table.Append(tblPr);
var grid = new TableGrid(
new GridColumn { Width = "3120" },
new GridColumn { Width = "3120" },
new GridColumn { Width = "3120" });
table.Append(grid);
var row = new TableRow();
// Left cell: logo/company name
var leftCell = new TableCell(
new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Left }),
new Run(
new RunProperties(new Bold(), new FontSize { Val = "18" }),
new Text("ACME Corp"))));
row.Append(leftCell);
// Center cell: document title
var centerCell = new TableCell(
new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Center }),
new Run(
new RunProperties(new FontSize { Val = "18" }),
new Text("Technical Report"))));
row.Append(centerCell);
// Right cell: page number
var pageNumPara = new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Right }));
pageNumPara.Append(new Run(
new RunProperties(new FontSize { Val = "18" }),
new Text("Page ") { Space = SpaceProcessingModeValues.Preserve }));
pageNumPara.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin }));
pageNumPara.Append(new Run(new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve }));
pageNumPara.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End }));
var rightCell = new TableCell(pageNumPara);
row.Append(rightCell);
table.Append(row);
headerPart.Header = new Header(table);
headerPart.Header.Save();
var headerRefId = mainPart.GetIdOfPart(headerPart);
sectPr.Append(new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = headerRefId
});
}
// ──────────────────────────────────────────────────────────────
// 9. AddChineseGongWenFooter — "-X-" format, SimSun 14pt
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Adds a Chinese government document (公文) style footer:
/// - Page number in "-X-" format (e.g., "- 1 -")
/// - Centered at bottom
/// - SimSun (宋体) font, 14pt (Chinese 四号)
///
/// XML:
/// <w:ftr>
/// <w:p>
/// <w:pPr><w:jc w:val="center"/></w:pPr>
/// <w:r>
/// <w:rPr>
/// <w:rFonts w:ascii="SimSun" w:eastAsia="SimSun"/>
/// <w:sz w:val="28"/>
/// </w:rPr>
/// <w:t xml:space="preserve">- </w:t>
/// </w:r>
/// <w:r>..PAGE field..</w:r>
/// <w:r>
/// <w:rPr>...</w:rPr>
/// <w:t xml:space="preserve"> -</w:t>
/// </w:r>
/// </w:p>
/// </w:ftr>
///
/// Chinese font size reference:
/// 四号 = 14pt = sz val="28" (half-points)
/// 小四 = 12pt = sz val="24"
/// 五号 = 10.5pt = sz val="21"
/// </summary>
public static void AddChineseGongWenFooter(MainDocumentPart mainPart, SectionProperties sectPr)
{
var footerPart = mainPart.AddNewPart<FooterPart>();
// Common run properties for the footer: SimSun 14pt (四号)
// 14pt = 28 half-points
RunProperties MakeGongWenRunProps() => new RunProperties(
new RunFonts { Ascii = "SimSun", EastAsia = "SimSun", HighAnsi = "SimSun" },
new FontSize { Val = "28" },
new FontSizeComplexScript { Val = "28" });
var paragraph = new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Center }));
// "- " prefix
paragraph.Append(new Run(
MakeGongWenRunProps(),
new Text("- ") { Space = SpaceProcessingModeValues.Preserve }));
// PAGE field with same formatting
paragraph.Append(new Run(
MakeGongWenRunProps(),
new FieldChar { FieldCharType = FieldCharValues.Begin }));
paragraph.Append(new Run(
MakeGongWenRunProps(),
new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve }));
paragraph.Append(new Run(
MakeGongWenRunProps(),
new FieldChar { FieldCharType = FieldCharValues.End }));
// " -" suffix
paragraph.Append(new Run(
MakeGongWenRunProps(),
new Text(" -") { Space = SpaceProcessingModeValues.Preserve }));
footerPart.Footer = new Footer(paragraph);
footerPart.Footer.Save();
var footerRefId = mainPart.GetIdOfPart(footerPart);
sectPr.Append(new FooterReference
{
Type = HeaderFooterValues.Default,
Id = footerRefId
});
}
// ──────────────────────────────────────────────────────────────
// 10. AddHeaderWithHorizontalLine — bottom border line
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Adds a header with a horizontal line (bottom border) beneath the text.
/// This is a common style: header text with a line separating it from content.
///
/// The line is achieved via a paragraph bottom border in the header, NOT a
/// separate drawing element.
///
/// XML:
/// <w:hdr>
/// <w:p>
/// <w:pPr>
/// <w:pBdr>
/// <w:bottom w:val="single" w:sz="6" w:space="1" w:color="000000"/>
/// </w:pBdr>
/// <w:jc w:val="center"/>
/// </w:pPr>
/// <w:r><w:t>Document Header</w:t></w:r>
/// </w:p>
/// </w:hdr>
///
/// Border space attribute: space between text and border line, in points.
/// Border size: in eighth-points (6 = 0.75pt).
/// </summary>
public static void AddHeaderWithHorizontalLine(MainDocumentPart mainPart, SectionProperties sectPr)
{
var headerPart = mainPart.AddNewPart<HeaderPart>();
var paragraph = new Paragraph(
new ParagraphProperties(
new ParagraphBorders(
new BottomBorder
{
Val = BorderValues.Single,
Size = 6, // 0.75pt line (in eighth-points)
Space = 1, // 1pt spacing between text and line
Color = "000000"
}),
new Justification { Val = JustificationValues.Center }),
new Run(
new RunProperties(
new Bold(),
new FontSize { Val = "20" }), // 10pt
new Text("Document Header")));
headerPart.Header = new Header(paragraph);
headerPart.Header.Save();
var headerRefId = mainPart.GetIdOfPart(headerPart);
sectPr.Append(new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = headerRefId
});
}
// ──────────────────────────────────────────────────────────────
// 11. ChangeHeaderPerSection — different headers per section
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Creates a document with multiple sections, each having its own header.
///
/// In OOXML, sections are delimited by SectionProperties:
/// - Inner sections: sectPr inside a Paragraph's ParagraphProperties (section break)
/// - Last section: sectPr as direct child of Body
///
/// Each sectPr can reference different HeaderPart/FooterPart via its own
/// HeaderReference/FooterReference elements.
///
/// XML structure for multi-section document:
/// <w:body>
/// <!-- Section 1 content -->
/// <w:p><w:r><w:t>Section 1 content</w:t></w:r></w:p>
/// <w:p>
/// <w:pPr>
/// <w:sectPr> <!-- Section 1 break -->
/// <w:headerReference w:type="default" r:id="rId_hdr1"/>
/// <w:type w:val="nextPage"/>
/// </w:sectPr>
/// </w:pPr>
/// </w:p>
///
/// <!-- Section 2 content -->
/// <w:p><w:r><w:t>Section 2 content</w:t></w:r></w:p>
///
/// <!-- Final section properties (last child of body) -->
/// <w:sectPr>
/// <w:headerReference w:type="default" r:id="rId_hdr2"/>
/// </w:sectPr>
/// </w:body>
///
/// GOTCHA: A section break sectPr is placed inside a paragraph's ParagraphProperties.
/// The paragraph that contains the sectPr is the LAST paragraph of that section.
///
/// GOTCHA: If a section does not have its own HeaderReference, it inherits
/// the header from the previous section. To have NO header in a section,
/// you must explicitly link to an empty HeaderPart.
/// </summary>
public static void ChangeHeaderPerSection(MainDocumentPart mainPart, Body body)
{
// --- Create two different header parts ---
// Header for Section 1
var header1Part = mainPart.AddNewPart<HeaderPart>();
header1Part.Header = new Header(
new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Left }),
new Run(new Text("Section 1 — Introduction"))));
header1Part.Header.Save();
// Header for Section 2
var header2Part = mainPart.AddNewPart<HeaderPart>();
header2Part.Header = new Header(
new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Left }),
new Run(new Text("Section 2 — Analysis"))));
header2Part.Header.Save();
// --- Section 1 content ---
body.Append(new Paragraph(
new Run(new Text("This is content in Section 1."))));
body.Append(new Paragraph(
new Run(new Text("More Section 1 content..."))));
// --- Section 1 break: sectPr inside a paragraph's pPr ---
// This paragraph is the LAST paragraph of Section 1.
var sect1Pr = new SectionProperties(
new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = mainPart.GetIdOfPart(header1Part)
},
// Section break type: start next section on a new page
new SectionType { Val = SectionMarkValues.NextPage });
// Page size and margins for section 1 (required for valid sectPr)
sect1Pr.Append(new DocumentFormat.OpenXml.Wordprocessing.PageSize
{
Width = (UInt32Value)12240U, // Letter width: 8.5" = 12240 DXA
Height = (UInt32Value)15840U // Letter height: 11" = 15840 DXA
});
sect1Pr.Append(new PageMargin
{
Top = 1440,
Bottom = 1440,
Left = (UInt32Value)1440U,
Right = (UInt32Value)1440U
});
// Wrap the sectPr in a paragraph's ParagraphProperties
var sectionBreakPara = new Paragraph(
new ParagraphProperties(sect1Pr));
body.Append(sectionBreakPara);
// --- Section 2 content ---
body.Append(new Paragraph(
new Run(new Text("This is content in Section 2."))));
body.Append(new Paragraph(
new Run(new Text("More Section 2 content..."))));
// --- Final section: sectPr as last child of Body ---
// This is the sectPr for the LAST section of the document.
var finalSectPr = new SectionProperties(
new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = mainPart.GetIdOfPart(header2Part)
});
finalSectPr.Append(new DocumentFormat.OpenXml.Wordprocessing.PageSize
{
Width = (UInt32Value)12240U,
Height = (UInt32Value)15840U
});
finalSectPr.Append(new PageMargin
{
Top = 1440,
Bottom = 1440,
Left = (UInt32Value)1440U,
Right = (UInt32Value)1440U
});
body.Append(finalSectPr);
}
}

View File

@@ -0,0 +1,917 @@
// ============================================================================
// ImageSamples.cs — Comprehensive OpenXML image handling reference
// ============================================================================
// EMU (English Metric Unit) is the universal measurement in DrawingML:
// 1 inch = 914400 EMU
// 1 cm = 360000 EMU
// 1 px@96dpi = 9525 EMU (914400 / 96 = 9525)
//
// Image architecture in OpenXML:
// Paragraph → Run → Drawing → DW.Inline (or DW.Anchor)
// → A.Graphic → A.GraphicData → PIC.Picture
// → PIC.BlipFill → A.Blip (references the image part via r:embed)
// → PIC.ShapeProperties → A.Transform2D → A.Extents (cx, cy)
//
// CRITICAL RULES:
// 1. Extent.Cx/Cy on DW.Inline/DW.Anchor MUST match A.Extents.Cx/Cy
// on PIC.ShapeProperties. Mismatch causes rendering issues.
// 2. Each Drawing element needs a unique DocProperties.Id within the document.
// 3. ImagePart must be added to the PART that references it:
// - MainDocumentPart for images in body
// - HeaderPart for images in headers
// - FooterPart for images in footers
// 4. Blip.Embed contains the relationship ID (rId) linking to the ImagePart.
// ============================================================================
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using A = DocumentFormat.OpenXml.Drawing;
using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing;
using PIC = DocumentFormat.OpenXml.Drawing.Pictures;
namespace MiniMaxAIDocx.Core.Samples;
/// <summary>
/// Reference implementations for every common image operation in OpenXML.
/// All methods produce valid, Word-renderable markup.
/// </summary>
public static class ImageSamples
{
// ── Constants ──────────────────────────────────────────────────────
private const long EmuPerInch = 914400L;
private const long EmuPerCm = 360000L;
private const long EmuPerPixel96Dpi = 9525L; // 914400 / 96
// GraphicData URI that tells Word "this is a picture"
private const string PicGraphicDataUri = "http://schemas.openxmlformats.org/drawingml/2006/picture";
// ── 1. Inline Image (most common) ──────────────────────────────────
/// <summary>
/// Inserts an inline image into the body. Inline images flow with text
/// and do not float. This is the most common image insertion pattern.
/// </summary>
/// <param name="mainPart">The MainDocumentPart to add the image relationship to.</param>
/// <param name="body">The Body element to append the paragraph to.</param>
/// <param name="imagePath">Filesystem path to the image file (png, jpg, etc.).</param>
/// <param name="widthPx">Desired display width in pixels (at 96 dpi).</param>
/// <param name="heightPx">Desired display height in pixels (at 96 dpi).</param>
public static void InsertInlineImage(
MainDocumentPart mainPart, Body body,
string imagePath, int widthPx, int heightPx)
{
// Step 1: Add the image file as a part. The ImagePartType must match
// the actual file format. AddImagePart returns the ImagePart; we then
// feed data into it.
var imageType = GetImagePartType(imagePath);
ImagePart imagePart = mainPart.AddImagePart(imageType);
using (FileStream stream = new FileStream(imagePath, FileMode.Open))
{
imagePart.FeedData(stream);
}
// Step 2: Get the relationship ID that links the Blip to this ImagePart.
string relId = mainPart.GetIdOfPart(imagePart);
// Step 3: Convert pixel dimensions to EMU.
// Formula: pixels * 9525 = EMU (at 96 dpi, which is Word's assumption)
long cx = widthPx * EmuPerPixel96Dpi;
long cy = heightPx * EmuPerPixel96Dpi;
// Step 4: Build the Drawing element using the reusable helper.
// docPropId must be unique across the entire document.
Drawing drawing = BuildDrawingElement(
relId, cx, cy,
docPropId: 1U,
name: "Image1",
description: null);
// Step 5: Wrap in Paragraph → Run → Drawing
Paragraph para = new Paragraph(
new Run(drawing));
body.AppendChild(para);
}
// ── 2. Floating Image (Anchor) ─────────────────────────────────────
/// <summary>
/// Inserts a floating image with absolute positioning using DW.Anchor.
/// Floating images are positioned relative to a reference point (page,
/// column, paragraph, etc.) and text wraps around them.
/// </summary>
public static void InsertFloatingImage(
MainDocumentPart mainPart, Body body, string imagePath)
{
ImagePart imagePart = mainPart.AddImagePart(GetImagePartType(imagePath));
using (FileStream stream = new FileStream(imagePath, FileMode.Open))
{
imagePart.FeedData(stream);
}
string relId = mainPart.GetIdOfPart(imagePart);
long cx = (long)(3.0 * EmuPerInch); // 3 inches wide
long cy = (long)(2.0 * EmuPerInch); // 2 inches tall
// DW.Anchor is used instead of DW.Inline for floating images.
// Key differences from Inline:
// - Has positioning (SimplePos, HorizontalPosition, VerticalPosition)
// - Has wrapping mode (WrapSquare, WrapTight, WrapNone, etc.)
// - Has BehindDoc and LayoutInCell flags
DW.Anchor anchor = new DW.Anchor(
// SimplePosition: when SimplePos=true, uses SimplePosition x/y directly.
// Normally false; we use HorizontalPosition/VerticalPosition instead.
new DW.SimplePosition { X = 0L, Y = 0L },
// HorizontalPosition: where the image sits horizontally.
// RelativeFrom can be: Column, Page, Margin, Character, LeftMargin, etc.
new DW.HorizontalPosition(
new DW.PositionOffset("914400") // 1 inch from reference
)
{ RelativeFrom = DW.HorizontalRelativePositionValues.Column },
// VerticalPosition: where the image sits vertically.
new DW.VerticalPosition(
new DW.PositionOffset("457200") // 0.5 inch from reference
)
{ RelativeFrom = DW.VerticalRelativePositionValues.Paragraph },
// Extent: overall size of the drawing object
new DW.Extent { Cx = cx, Cy = cy },
// EffectExtent: extra space for shadows, glow, etc. (0 if none)
new DW.EffectExtent
{
LeftEdge = 0L,
TopEdge = 0L,
RightEdge = 0L,
BottomEdge = 0L
},
// WrapSquare: text wraps in a square around the image bounding box.
new DW.WrapSquare { WrapText = DW.WrapTextValues.BothSides },
// DocProperties: unique ID + name for the drawing object
new DW.DocProperties { Id = 2U, Name = "FloatingImage1" },
// Non-visual graphic frame properties (required but usually empty)
new DW.NonVisualGraphicFrameDrawingProperties(
new A.GraphicFrameLocks { NoChangeAspect = true }),
// The actual graphic content
new A.Graphic(
new A.GraphicData(
new PIC.Picture(
new PIC.NonVisualPictureProperties(
new PIC.NonVisualDrawingProperties
{
Id = 0U,
Name = "FloatingImage1.png"
},
new PIC.NonVisualPictureDrawingProperties()),
new PIC.BlipFill(
new A.Blip { Embed = relId },
new A.Stretch(new A.FillRectangle())),
new PIC.ShapeProperties(
new A.Transform2D(
new A.Offset { X = 0L, Y = 0L },
// CRITICAL: These cx/cy MUST match the Extent above
new A.Extents { Cx = cx, Cy = cy }),
new A.PresetGeometry(
new A.AdjustValueList())
{ Preset = A.ShapeTypeValues.Rectangle }))
)
{ Uri = PicGraphicDataUri })
)
{
// Anchor attributes
DistanceFromTop = 0U,
DistanceFromBottom = 0U,
DistanceFromLeft = 114300U, // ~0.125 inch gap between text and image
DistanceFromRight = 114300U,
SimplePos = false,
RelativeHeight = 251658240U, // z-order; higher = in front
BehindDoc = false, // true = behind text (like a watermark)
Locked = false,
LayoutInCell = true,
AllowOverlap = true
};
Paragraph para = new Paragraph(new Run(new Drawing(anchor)));
body.AppendChild(para);
}
// ── 3. Image with Various Text Wrapping ────────────────────────────
/// <summary>
/// Demonstrates the four main text wrapping modes for floating images.
/// Each wrapping mode controls how body text flows around the image.
/// </summary>
public static void InsertImageWithTextWrapping(
MainDocumentPart mainPart, Body body, string imagePath)
{
// All wrapping modes require DW.Anchor (not DW.Inline).
// The wrapping element is a direct child of the Anchor element.
ImagePart imagePart = mainPart.AddImagePart(GetImagePartType(imagePath));
using (FileStream stream = new FileStream(imagePath, FileMode.Open))
{
imagePart.FeedData(stream);
}
string relId = mainPart.GetIdOfPart(imagePart);
long cx = (long)(2.5 * EmuPerInch);
long cy = (long)(2.0 * EmuPerInch);
// ── WrapSquare ──
// Text wraps in a rectangular bounding box around the image.
// WrapText controls which sides text appears on.
var wrapSquare = new DW.WrapSquare
{
WrapText = DW.WrapTextValues.BothSides
// Other options: Left, Right, Largest
};
// ── WrapTight ──
// Text wraps tightly around the actual contour of the image.
// Uses a WrapPolygon to define the outline; Word can auto-generate this.
// The coordinates are in EMU relative to the image's top-left.
var wrapTight = new DW.WrapTight(
new DW.WrapPolygon(
new DW.StartPoint { X = 0L, Y = 0L },
new DW.LineTo { X = 0L, Y = 21600L },
new DW.LineTo { X = 21600L, Y = 21600L },
new DW.LineTo { X = 21600L, Y = 0L },
new DW.LineTo { X = 0L, Y = 0L }
)
{ Edited = false }
)
{
WrapText = DW.WrapTextValues.BothSides
};
// ── WrapTopAndBottom ──
// No text appears beside the image. Text only above and below.
// This effectively makes the image act as a block-level element
// but still floating (not inline).
var wrapTopAndBottom = new DW.WrapTopBottom
{
DistanceFromTop = 0U,
DistanceFromBottom = 0U
};
// ── WrapNone ──
// No text wrapping at all. Image floats over or behind text.
// Combined with BehindDoc=true, this creates a watermark effect.
var wrapNone = new DW.WrapNone();
// Example: build anchor with WrapSquare (swap in any wrapping element above)
DW.Anchor anchor = BuildAnchorElement(
relId, cx, cy,
docPropId: 3U,
name: "WrappedImage",
wrapElement: wrapSquare,
behindDoc: false);
body.AppendChild(new Paragraph(new Run(new Drawing(anchor))));
}
// ── 4. Image with Border ───────────────────────────────────────────
/// <summary>
/// Inserts an image with a visible outline/border. The border is applied
/// via A.Outline on the PIC.ShapeProperties element.
/// </summary>
public static void InsertImageWithBorder(
MainDocumentPart mainPart, Body body, string imagePath)
{
ImagePart imagePart = mainPart.AddImagePart(GetImagePartType(imagePath));
using (FileStream stream = new FileStream(imagePath, FileMode.Open))
{
imagePart.FeedData(stream);
}
string relId = mainPart.GetIdOfPart(imagePart);
long cx = (long)(3.0 * EmuPerInch);
long cy = (long)(2.0 * EmuPerInch);
// Build PIC.ShapeProperties with an Outline element for the border.
// Outline width is in EMU. 1pt = 12700 EMU.
var shapeProperties = new PIC.ShapeProperties(
new A.Transform2D(
new A.Offset { X = 0L, Y = 0L },
new A.Extents { Cx = cx, Cy = cy }),
new A.PresetGeometry(
new A.AdjustValueList())
{ Preset = A.ShapeTypeValues.Rectangle },
// The Outline element defines the border
new A.Outline(
// SolidFill sets the border color
new A.SolidFill(
new A.RgbColorModelHex { Val = "2F5496" }), // Dark blue
// PresetDash sets the line style (solid, dash, dot, etc.)
new A.PresetDash { Val = A.PresetLineDashValues.Solid }
)
{
Width = 25400, // 2pt border (12700 EMU per pt)
CompoundLineType = A.CompoundLineValues.Single
}
);
var picture = new PIC.Picture(
new PIC.NonVisualPictureProperties(
new PIC.NonVisualDrawingProperties { Id = 0U, Name = "BorderedImage.png" },
new PIC.NonVisualPictureDrawingProperties()),
new PIC.BlipFill(
new A.Blip { Embed = relId },
new A.Stretch(new A.FillRectangle())),
shapeProperties);
var drawing = new Drawing(
new DW.Inline(
new DW.Extent { Cx = cx, Cy = cy },
new DW.EffectExtent
{
// Must account for border width in effect extent so it is not clipped
LeftEdge = 25400L,
TopEdge = 25400L,
RightEdge = 25400L,
BottomEdge = 25400L
},
new DW.DocProperties { Id = 4U, Name = "BorderedImage" },
new DW.NonVisualGraphicFrameDrawingProperties(
new A.GraphicFrameLocks { NoChangeAspect = true }),
new A.Graphic(
new A.GraphicData(picture)
{ Uri = PicGraphicDataUri })
)
{
DistanceFromTop = 0U,
DistanceFromBottom = 0U,
DistanceFromLeft = 0U,
DistanceFromRight = 0U
});
body.AppendChild(new Paragraph(new Run(drawing)));
}
// ── 5. Image with Alt Text ─────────────────────────────────────────
/// <summary>
/// Inserts an image with alt text for accessibility. The alt text is set
/// on the DocProperties.Description attribute. Screen readers use this.
/// Word also shows it in the "Alt Text" pane.
/// </summary>
public static void InsertImageWithAltText(
MainDocumentPart mainPart, Body body, string imagePath)
{
ImagePart imagePart = mainPart.AddImagePart(GetImagePartType(imagePath));
using (FileStream stream = new FileStream(imagePath, FileMode.Open))
{
imagePart.FeedData(stream);
}
string relId = mainPart.GetIdOfPart(imagePart);
long cx = (long)(3.0 * EmuPerInch);
long cy = (long)(2.0 * EmuPerInch);
// DocProperties.Description is the standard alt text field.
// DocProperties.Title is an optional short title shown in some UIs.
Drawing drawing = BuildDrawingElement(
relId, cx, cy,
docPropId: 5U,
name: "AccessibleImage",
description: "A chart showing quarterly revenue growth from Q1 to Q4 2025");
body.AppendChild(new Paragraph(new Run(drawing)));
}
// ── 6. Image in Header ─────────────────────────────────────────────
/// <summary>
/// Inserts an image into a header part. The image relationship MUST be
/// added to the HeaderPart, NOT the MainDocumentPart. If you add it
/// to MainDocumentPart, Word will show a broken image in the header
/// because relationship IDs are scoped to their containing part.
/// </summary>
public static void InsertImageInHeader(HeaderPart headerPart, string imagePath)
{
// CRITICAL: AddImagePart to headerPart, not mainDocumentPart!
// Each OpenXML part has its own relationship namespace.
// An rId in the header must point to a relationship in the header's .rels file.
ImagePart imagePart = headerPart.AddImagePart(GetImagePartType(imagePath));
using (FileStream stream = new FileStream(imagePath, FileMode.Open))
{
imagePart.FeedData(stream);
}
// GetIdOfPart must also be called on headerPart
string relId = headerPart.GetIdOfPart(imagePart);
long cx = (long)(1.5 * EmuPerInch); // Company logo, typically small
long cy = (long)(0.5 * EmuPerInch);
Drawing drawing = BuildDrawingElement(
relId, cx, cy,
docPropId: 6U,
name: "HeaderLogo",
description: "Company logo");
// Headers use the Header element with Paragraph children (same as Body)
Header header = headerPart.Header;
Paragraph para = new Paragraph(
new ParagraphProperties(
new Justification { Val = JustificationValues.Center }),
new Run(drawing));
header.AppendChild(para);
}
// ── 7. Image in Table Cell ─────────────────────────────────────────
/// <summary>
/// Inserts an image into a table cell, sized to fit. Table cells constrain
/// content width, so we calculate appropriate dimensions to avoid overflow.
/// The image part is still added to MainDocumentPart (the cell is in the body).
/// </summary>
/// <param name="mainPart">MainDocumentPart (owns the relationship).</param>
/// <param name="cell">The TableCell to insert the image into.</param>
/// <param name="imagePath">Path to the image file.</param>
public static void InsertImageInTableCell(
MainDocumentPart mainPart, TableCell cell, string imagePath)
{
ImagePart imagePart = mainPart.AddImagePart(GetImagePartType(imagePath));
using (FileStream stream = new FileStream(imagePath, FileMode.Open))
{
imagePart.FeedData(stream);
}
string relId = mainPart.GetIdOfPart(imagePart);
// Determine cell width from TableCellWidth if available.
// TableCellWidth.Width is in DXA (twentieths of a point).
// If not set, use a reasonable default (e.g., 2 inches).
long maxWidthEmu = (long)(2.0 * EmuPerInch); // default
TableCellProperties? tcPr = cell.GetFirstChild<TableCellProperties>();
TableCellWidth? tcWidth = tcPr?.GetFirstChild<TableCellWidth>();
if (tcWidth?.Width is not null && tcWidth.Type?.Value == TableWidthUnitValues.Dxa)
{
// Convert DXA to EMU: 1 DXA = 1/20 pt = 1/1440 inch = 914400/1440 EMU
int dxa = int.Parse(tcWidth.Width);
maxWidthEmu = (long)(dxa * (EmuPerInch / 1440.0));
}
// Calculate image dimensions to fit within the cell width
(long cx, long cy) = CalculateImageDimensions(imagePath, maxWidthEmu / (double)EmuPerInch);
Drawing drawing = BuildDrawingElement(
relId, cx, cy,
docPropId: 7U,
name: "CellImage",
description: null);
// A TableCell MUST contain at least one Paragraph.
// We add the image inside that paragraph.
Paragraph para = cell.GetFirstChild<Paragraph>() ?? cell.AppendChild(new Paragraph());
para.AppendChild(new Run(drawing));
}
// ── 8. Replace Existing Image ──────────────────────────────────────
/// <summary>
/// Replaces an existing image by updating the ImagePart data behind a
/// known relationship ID. The Blip.Embed attribute (rId) stays the same;
/// only the binary content changes. This avoids needing to rebuild the
/// entire Drawing XML tree.
/// </summary>
/// <param name="mainPart">The MainDocumentPart containing the image relationship.</param>
/// <param name="oldRelId">The existing relationship ID (e.g., "rId5") of the image to replace.</param>
/// <param name="newImagePath">Path to the replacement image file.</param>
public static void ReplaceExistingImage(
MainDocumentPart mainPart, string oldRelId, string newImagePath)
{
// Look up the existing ImagePart by its relationship ID
OpenXmlPart part = mainPart.GetPartById(oldRelId);
if (part is not ImagePart imagePart)
{
throw new InvalidOperationException(
$"Relationship {oldRelId} does not point to an ImagePart.");
}
// Feed new image data into the existing part.
// This replaces the binary content while keeping the same rId.
using (FileStream stream = new FileStream(newImagePath, FileMode.Open))
{
imagePart.FeedData(stream);
}
// NOTE: If the new image has different dimensions, you should also
// update the Extent.Cx/Cy and A.Extents.Cx/Cy in the Drawing element.
// Find all Blip elements referencing this relId:
//
// var blips = mainPart.Document.Descendants<A.Blip>()
// .Where(b => b.Embed == oldRelId);
// foreach (var blip in blips)
// {
// // Navigate up to find the Extent and A.Extents to update dimensions
// }
}
// ── 9. SVG with PNG Fallback ───────────────────────────────────────
/// <summary>
/// Inserts an SVG image with a PNG fallback for compatibility.
/// Word 2019+ supports SVG natively; older versions show the PNG.
/// The SVG is referenced via an extension element (SvgBlip) inside the Blip,
/// while the Blip.Embed itself points to the PNG fallback.
/// </summary>
public static void InsertSvgWithPngFallback(
MainDocumentPart mainPart, Body body,
string svgPath, string pngFallbackPath)
{
// Add PNG fallback as the primary image part
ImagePart pngPart = mainPart.AddImagePart(ImagePartType.Png);
using (FileStream pngStream = new FileStream(pngFallbackPath, FileMode.Open))
{
pngPart.FeedData(pngStream);
}
string pngRelId = mainPart.GetIdOfPart(pngPart);
// Add SVG as a separate image part
ImagePart svgPart = mainPart.AddImagePart(ImagePartType.Svg);
using (FileStream svgStream = new FileStream(svgPath, FileMode.Open))
{
svgPart.FeedData(svgStream);
}
string svgRelId = mainPart.GetIdOfPart(svgPart);
long cx = (long)(3.0 * EmuPerInch);
long cy = (long)(3.0 * EmuPerInch);
// The Blip.Embed points to the PNG fallback.
// The SVG is added as an extension element (asvg:svgBlip) inside the Blip.
// Namespace: http://schemas.microsoft.com/office/drawing/2016/SVG/main
var blip = new A.Blip { Embed = pngRelId };
// Add SVG extension to the Blip using BlipExtensionList
var svgExtension = new A.BlipExtensionList(
new A.BlipExtension(
// The SVG blip element references the SVG image part
new OpenXmlUnknownElement(
"asvg", "svgBlip",
"http://schemas.microsoft.com/office/drawing/2016/SVG/main")
// NOTE: In production, set the r:embed attribute on this element
// to svgRelId. OpenXmlUnknownElement requires manual attribute setting.
)
{ Uri = "{96DAC541-7B7A-43D3-8B79-37D633B846F1}" }
);
blip.Append(svgExtension);
var picture = new PIC.Picture(
new PIC.NonVisualPictureProperties(
new PIC.NonVisualDrawingProperties { Id = 0U, Name = "SvgImage.svg" },
new PIC.NonVisualPictureDrawingProperties()),
new PIC.BlipFill(
blip,
new A.Stretch(new A.FillRectangle())),
new PIC.ShapeProperties(
new A.Transform2D(
new A.Offset { X = 0L, Y = 0L },
new A.Extents { Cx = cx, Cy = cy }),
new A.PresetGeometry(new A.AdjustValueList())
{ Preset = A.ShapeTypeValues.Rectangle }));
var drawing = new Drawing(
new DW.Inline(
new DW.Extent { Cx = cx, Cy = cy },
new DW.EffectExtent
{
LeftEdge = 0L, TopEdge = 0L,
RightEdge = 0L, BottomEdge = 0L
},
new DW.DocProperties { Id = 9U, Name = "SvgImage" },
new DW.NonVisualGraphicFrameDrawingProperties(
new A.GraphicFrameLocks { NoChangeAspect = true }),
new A.Graphic(
new A.GraphicData(picture)
{ Uri = PicGraphicDataUri })
)
{
DistanceFromTop = 0U,
DistanceFromBottom = 0U,
DistanceFromLeft = 0U,
DistanceFromRight = 0U
});
body.AppendChild(new Paragraph(new Run(drawing)));
}
// ── 10. Calculate Image Dimensions ─────────────────────────────────
/// <summary>
/// Reads the actual pixel dimensions of an image file (PNG or JPEG) and
/// calculates EMU values that fit within a maximum width while maintaining
/// the original aspect ratio. Uses raw byte reading to avoid a dependency
/// on System.Drawing (which is Windows-only on modern .NET).
/// </summary>
/// <param name="imagePath">Path to a PNG or JPEG image file.</param>
/// <param name="maxWidthInches">Maximum allowed width in inches.</param>
/// <returns>Tuple of (cx, cy) in EMU, scaled to fit maxWidthInches.</returns>
/// <remarks>
/// For production use, consider SkiaSharp or SixLabors.ImageSharp for
/// cross-platform image metadata reading with broader format support.
/// This implementation handles PNG and JPEG only.
/// </remarks>
public static (long cx, long cy) CalculateImageDimensions(
string imagePath, double maxWidthInches)
{
// Read pixel dimensions from the image file header.
// We parse PNG IHDR or JPEG SOF0 markers directly to avoid
// pulling in System.Drawing.Common (Windows-only on .NET 6+).
(int widthPx, int heightPx, double dpiX, double dpiY) = ReadImageMetadata(imagePath);
// Calculate actual size in inches based on pixel count and DPI
double widthInches = widthPx / dpiX;
double heightInches = heightPx / dpiY;
// Scale down if wider than maxWidthInches, preserving aspect ratio
if (widthInches > maxWidthInches)
{
double scale = maxWidthInches / widthInches;
widthInches = maxWidthInches;
heightInches *= scale;
}
long cx = (long)(widthInches * EmuPerInch);
long cy = (long)(heightInches * EmuPerInch);
return (cx, cy);
}
/// <summary>
/// Reads width, height, and DPI from a PNG or JPEG file header.
/// Returns 96 DPI as default if DPI metadata is not found.
/// </summary>
private static (int widthPx, int heightPx, double dpiX, double dpiY) ReadImageMetadata(
string imagePath)
{
const double DefaultDpi = 96.0;
byte[] header = new byte[32];
using var fs = new FileStream(imagePath, FileMode.Open, FileAccess.Read);
int bytesRead = fs.Read(header, 0, header.Length);
// PNG: starts with 0x89 0x50 0x4E 0x47 (‰PNG)
// IHDR chunk is always first; width and height are at bytes 16-23 (big-endian)
if (bytesRead >= 24 &&
header[0] == 0x89 && header[1] == 0x50 &&
header[2] == 0x4E && header[3] == 0x47)
{
int width = (header[16] << 24) | (header[17] << 16) |
(header[18] << 8) | header[19];
int height = (header[20] << 24) | (header[21] << 16) |
(header[22] << 8) | header[23];
// PNG DPI is in the pHYs chunk (not in IHDR); use default for simplicity
return (width, height, DefaultDpi, DefaultDpi);
}
// JPEG: starts with 0xFF 0xD8
// Scan for SOF0 (0xFF 0xC0) marker to find dimensions
if (bytesRead >= 2 && header[0] == 0xFF && header[1] == 0xD8)
{
fs.Position = 2;
while (fs.Position < fs.Length - 1)
{
int b = fs.ReadByte();
if (b != 0xFF) continue;
int marker = fs.ReadByte();
if (marker == -1) break;
// SOF0 (0xC0) or SOF2 (0xC2, progressive)
if (marker == 0xC0 || marker == 0xC2)
{
byte[] sof = new byte[7];
if (fs.Read(sof, 0, 7) == 7)
{
// SOF structure: length(2) + precision(1) + height(2) + width(2)
int height = (sof[3] << 8) | sof[4];
int width = (sof[5] << 8) | sof[6];
return (width, height, DefaultDpi, DefaultDpi);
}
break;
}
// Skip other markers: read 2-byte length and advance
if (marker is not (0xD0 or 0xD1 or 0xD2 or 0xD3 or 0xD4 or
0xD5 or 0xD6 or 0xD7 or 0xD8 or 0xD9 or 0x01))
{
byte[] lenBytes = new byte[2];
if (fs.Read(lenBytes, 0, 2) < 2) break;
int len = (lenBytes[0] << 8) | lenBytes[1];
if (len < 2) break;
fs.Position += len - 2;
}
}
}
// Fallback: cannot determine dimensions; return a reasonable default
// Caller should handle this gracefully.
return (300, 200, DefaultDpi, DefaultDpi);
}
// ── 11. Reusable Drawing Builder (Inline) ──────────────────────────
/// <summary>
/// Builds a complete Drawing element for an inline image. This is the
/// reusable core that most insertion methods delegate to.
/// </summary>
/// <param name="relId">Relationship ID pointing to the ImagePart (e.g., "rId4").</param>
/// <param name="cx">Image width in EMU. Must be positive.</param>
/// <param name="cy">Image height in EMU. Must be positive.</param>
/// <param name="docPropId">Unique ID for DocProperties within the document.
/// Each Drawing in a document must have a distinct DocProperties.Id.</param>
/// <param name="name">Name for DocProperties (shows in Word selection pane).</param>
/// <param name="description">Alt text for accessibility. Null if not needed.</param>
/// <returns>A fully constructed Drawing element ready to append to a Run.</returns>
public static Drawing BuildDrawingElement(
string relId, long cx, long cy,
uint docPropId, string name, string? description)
{
// ── Complete element hierarchy ──
// Drawing
// └─ DW.Inline
// ├─ DW.Extent (cx, cy) ← bounding box size
// ├─ DW.EffectExtent ← extra space for effects
// ├─ DW.DocProperties (id, name, descr) ← identity + alt text
// ├─ DW.NonVisualGraphicFrameDrawingProperties
// │ └─ A.GraphicFrameLocks ← lock aspect ratio
// └─ A.Graphic
// └─ A.GraphicData (uri = picture namespace)
// └─ PIC.Picture
// ├─ PIC.NonVisualPictureProperties
// │ ├─ PIC.NonVisualDrawingProperties
// │ └─ PIC.NonVisualPictureDrawingProperties
// ├─ PIC.BlipFill
// │ ├─ A.Blip (embed = relId)
// │ └─ A.Stretch → A.FillRectangle
// └─ PIC.ShapeProperties
// ├─ A.Transform2D
// │ ├─ A.Offset (0, 0)
// │ └─ A.Extents (cx, cy) ← MUST match DW.Extent!
// └─ A.PresetGeometry (rect)
var docProps = new DW.DocProperties
{
Id = docPropId,
Name = name
};
if (description is not null)
{
docProps.Description = description;
}
var picture = new PIC.Picture(
new PIC.NonVisualPictureProperties(
new PIC.NonVisualDrawingProperties
{
Id = 0U,
Name = name
},
new PIC.NonVisualPictureDrawingProperties()),
new PIC.BlipFill(
new A.Blip
{
Embed = relId,
// CompressionState controls image quality vs file size.
// Print = high quality, Screen = medium, Email = low, None = original
CompressionState = A.BlipCompressionValues.Print
},
new A.Stretch(new A.FillRectangle())),
new PIC.ShapeProperties(
new A.Transform2D(
new A.Offset { X = 0L, Y = 0L },
new A.Extents { Cx = cx, Cy = cy }), // MUST match DW.Extent
new A.PresetGeometry(
new A.AdjustValueList())
{ Preset = A.ShapeTypeValues.Rectangle }));
var inline = new DW.Inline(
new DW.Extent { Cx = cx, Cy = cy }, // MUST match A.Extents
new DW.EffectExtent
{
LeftEdge = 0L,
TopEdge = 0L,
RightEdge = 0L,
BottomEdge = 0L
},
docProps,
new DW.NonVisualGraphicFrameDrawingProperties(
new A.GraphicFrameLocks { NoChangeAspect = true }),
new A.Graphic(
new A.GraphicData(picture)
{ Uri = PicGraphicDataUri }))
{
DistanceFromTop = 0U,
DistanceFromBottom = 0U,
DistanceFromLeft = 0U,
DistanceFromRight = 0U
};
return new Drawing(inline);
}
// ── Private Helpers ────────────────────────────────────────────────
/// <summary>
/// Builds a DW.Anchor element for floating images with configurable wrapping.
/// </summary>
private static DW.Anchor BuildAnchorElement(
string relId, long cx, long cy,
uint docPropId, string name,
OpenXmlElement wrapElement,
bool behindDoc)
{
return new DW.Anchor(
new DW.SimplePosition { X = 0L, Y = 0L },
new DW.HorizontalPosition(
new DW.PositionOffset("0"))
{ RelativeFrom = DW.HorizontalRelativePositionValues.Column },
new DW.VerticalPosition(
new DW.PositionOffset("0"))
{ RelativeFrom = DW.VerticalRelativePositionValues.Paragraph },
new DW.Extent { Cx = cx, Cy = cy },
new DW.EffectExtent
{
LeftEdge = 0L,
TopEdge = 0L,
RightEdge = 0L,
BottomEdge = 0L
},
wrapElement,
new DW.DocProperties { Id = docPropId, Name = name },
new DW.NonVisualGraphicFrameDrawingProperties(
new A.GraphicFrameLocks { NoChangeAspect = true }),
new A.Graphic(
new A.GraphicData(
new PIC.Picture(
new PIC.NonVisualPictureProperties(
new PIC.NonVisualDrawingProperties
{
Id = 0U,
Name = name
},
new PIC.NonVisualPictureDrawingProperties()),
new PIC.BlipFill(
new A.Blip { Embed = relId },
new A.Stretch(new A.FillRectangle())),
new PIC.ShapeProperties(
new A.Transform2D(
new A.Offset { X = 0L, Y = 0L },
new A.Extents { Cx = cx, Cy = cy }),
new A.PresetGeometry(
new A.AdjustValueList())
{ Preset = A.ShapeTypeValues.Rectangle }))
)
{ Uri = PicGraphicDataUri })
)
{
DistanceFromTop = 0U,
DistanceFromBottom = 0U,
DistanceFromLeft = 114300U,
DistanceFromRight = 114300U,
SimplePos = false,
RelativeHeight = 251658240U,
BehindDoc = behindDoc,
Locked = false,
LayoutInCell = true,
AllowOverlap = true
};
}
/// <summary>
/// Maps file extensions to OpenXML PartTypeInfo values via ImagePartType.
/// In SDK 3.x, ImagePartType is a static class whose members return PartTypeInfo.
/// </summary>
private static PartTypeInfo GetImagePartType(string imagePath)
{
string ext = Path.GetExtension(imagePath).ToLowerInvariant();
return ext switch
{
".png" => ImagePartType.Png,
".jpg" or ".jpeg" => ImagePartType.Jpeg,
".gif" => ImagePartType.Gif,
".bmp" => ImagePartType.Bmp,
".tif" or ".tiff" => ImagePartType.Tiff,
".svg" => ImagePartType.Svg,
".emf" => ImagePartType.Emf,
".wmf" => ImagePartType.Wmf,
".ico" => ImagePartType.Icon,
_ => throw new NotSupportedException(
$"Image format '{ext}' is not supported by OpenXML.")
};
}
}

View File

@@ -0,0 +1,826 @@
// ============================================================================
// ListAndNumberingSamples.cs — OpenXML numbering system deep dive
// ============================================================================
// OpenXML list/numbering architecture (3 layers):
//
// 1. AbstractNum — defines the numbering FORMAT (bullet chars, number formats,
// indentation, fonts). Contains Level elements (0-8) for multi-level lists.
//
// 2. NumberingInstance (Num) — a concrete "instance" that references an
// AbstractNum. Multiple paragraphs share the same NumId to form one list.
// LevelOverride on a NumberingInstance can restart numbering.
//
// 3. NumberingProperties on Paragraph — links a paragraph to a NumberingInstance
// via NumId + Level (ilvl). This is what makes a paragraph a list item.
//
// CRITICAL RULES:
// - In the Numbering root element, ALL AbstractNum elements MUST appear
// BEFORE any NumberingInstance (Num) elements. Violating this order causes
// Word to report corruption.
// - LevelText uses %1, %2, %3 etc. as placeholders for the current value
// at each level. %1 = level 0's value, %2 = level 1's value, etc.
// - NumberingSymbolRunProperties (rPr inside Level) sets the font for the
// bullet character or number. Without it, the bullet may render in the
// paragraph's font, which can produce wrong glyphs.
// - IsLegalNumberingStyle on a Level forces "legal" flat numbering
// (e.g., "1.1.1" instead of outline style) regardless of heading level.
//
// Storage: Numbering definitions live in numbering.xml, accessed via
// NumberingDefinitionsPart on the MainDocumentPart.
// ============================================================================
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using A = DocumentFormat.OpenXml.Drawing;
using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing;
using PIC = DocumentFormat.OpenXml.Drawing.Pictures;
namespace MiniMaxAIDocx.Core.Samples;
/// <summary>
/// Reference implementations for bullet lists, numbered lists, custom numbering,
/// and all related numbering infrastructure in OpenXML.
/// </summary>
public static class ListAndNumberingSamples
{
// ── 1. Bullet List (3 levels) ──────────────────────────────────────
/// <summary>
/// Creates a 3-level bullet list: bullet (•) → circle (○) → square (■).
/// Uses Symbol font for standard bullet characters.
/// </summary>
public static void CreateBulletList(
NumberingDefinitionsPart numPart, Body body)
{
int abstractNumId = 0;
int numId = 1;
// Level 0: solid bullet • (Unicode F0B7 in Symbol font)
// Level 1: open circle ○ (Unicode F06F in Symbol font = ○, or "o" in Courier New)
// Level 2: solid square ■ (Unicode F0A7 in Wingdings)
var levels = new Level[]
{
CreateBulletLevel(
levelIndex: 0,
bulletChar: "\xF0B7", // • in Symbol
font: "Symbol",
indentLeftDxa: 720, // 0.5 inch
hangingDxa: 360), // bullet hangs 0.25 inch
CreateBulletLevel(
levelIndex: 1,
bulletChar: "o", // ○ in Courier New
font: "Courier New",
indentLeftDxa: 1440, // 1.0 inch
hangingDxa: 360),
CreateBulletLevel(
levelIndex: 2,
bulletChar: "\xF0A7", // ■ in Wingdings
font: "Wingdings",
indentLeftDxa: 2160, // 1.5 inch
hangingDxa: 360)
};
// Build the abstract numbering definition and instance
SetupAbstractNum(numPart, abstractNumId, levels);
SetupNumberingInstance(numPart, numId, abstractNumId);
// Create sample list items at each level
string[] level0Items = ["First item", "Second item", "Third item"];
string[] level1Items = ["Sub-item A", "Sub-item B"];
string[] level2Items = ["Detail 1", "Detail 2"];
foreach (string text in level0Items)
{
Paragraph para = CreateListParagraph(text, numId, level: 0);
body.AppendChild(para);
}
foreach (string text in level1Items)
{
Paragraph para = CreateListParagraph(text, numId, level: 1);
body.AppendChild(para);
}
foreach (string text in level2Items)
{
Paragraph para = CreateListParagraph(text, numId, level: 2);
body.AppendChild(para);
}
}
// ── 2. Numbered List (3 levels) ────────────────────────────────────
/// <summary>
/// Creates a 3-level numbered list: 1. → 1.1. → 1.1.1.
/// Uses NumberFormatValues.Decimal with compound LevelText patterns.
/// </summary>
public static void CreateNumberedList(
NumberingDefinitionsPart numPart, Body body)
{
int abstractNumId = 1;
int numId = 2;
// LevelText explanation:
// "%1" → just the level-0 counter: 1, 2, 3...
// "%1.%2" → level-0.level-1: 1.1, 1.2, 2.1...
// "%1.%2.%3" → level-0.level-1.level-2: 1.1.1, 1.1.2...
var levels = new Level[]
{
CreateNumberLevel(
levelIndex: 0,
format: NumberFormatValues.Decimal,
levelText: "%1.", // "1.", "2.", "3."
indentLeftDxa: 720,
hangingDxa: 360,
start: 1),
CreateNumberLevel(
levelIndex: 1,
format: NumberFormatValues.Decimal,
levelText: "%1.%2.", // "1.1.", "1.2.", "2.1."
indentLeftDxa: 1440,
hangingDxa: 720, // wider hanging for "1.1."
start: 1),
CreateNumberLevel(
levelIndex: 2,
format: NumberFormatValues.Decimal,
levelText: "%1.%2.%3.", // "1.1.1.", "1.1.2."
indentLeftDxa: 2160,
hangingDxa: 1080,
start: 1)
};
SetupAbstractNum(numPart, abstractNumId, levels);
SetupNumberingInstance(numPart, numId, abstractNumId);
// Sample items
body.AppendChild(CreateListParagraph("Chapter One", numId, level: 0));
body.AppendChild(CreateListParagraph("Section One", numId, level: 1));
body.AppendChild(CreateListParagraph("Detail A", numId, level: 2));
body.AppendChild(CreateListParagraph("Detail B", numId, level: 2));
body.AppendChild(CreateListParagraph("Section Two", numId, level: 1));
body.AppendChild(CreateListParagraph("Chapter Two", numId, level: 0));
}
// ── 3. Custom Bullet Characters ────────────────────────────────────
/// <summary>
/// Creates bullets with custom Unicode characters: ✓ (check), ➢ (arrow), ★ (star).
/// Uses specific fonts that contain these glyphs.
/// </summary>
public static void CreateCustomBullets(
NumberingDefinitionsPart numPart, Body body)
{
int abstractNumId = 2;
int numId = 3;
// For custom Unicode bullets, the font in NumberingSymbolRunProperties
// MUST contain the glyph. Common choices:
// - "Segoe UI Symbol" — broad Unicode coverage on Windows
// - "Arial Unicode MS" — wide coverage
// - "Wingdings" / "Webdings" — symbol fonts (use their private codepoints)
var levels = new Level[]
{
CreateBulletLevel(
levelIndex: 0,
bulletChar: "\u2713", // ✓ CHECK MARK
font: "Segoe UI Symbol",
indentLeftDxa: 720,
hangingDxa: 360),
CreateBulletLevel(
levelIndex: 1,
bulletChar: "\u27A2", // ➢ THREE-D TOP-LIGHTED RIGHTWARDS ARROWHEAD
font: "Segoe UI Symbol",
indentLeftDxa: 1440,
hangingDxa: 360),
CreateBulletLevel(
levelIndex: 2,
bulletChar: "\u2605", // ★ BLACK STAR
font: "Segoe UI Symbol",
indentLeftDxa: 2160,
hangingDxa: 360)
};
SetupAbstractNum(numPart, abstractNumId, levels);
SetupNumberingInstance(numPart, numId, abstractNumId);
body.AppendChild(CreateListParagraph("Completed task", numId, level: 0));
body.AppendChild(CreateListParagraph("Action item", numId, level: 1));
body.AppendChild(CreateListParagraph("Starred note", numId, level: 2));
}
// ── 4. Outline Numbering Linked to Heading Styles ──────────────────
/// <summary>
/// Creates outline numbering (Article 1, Section 1.1, etc.) linked to
/// Heading1, Heading2, Heading3 styles. This is how Word's built-in
/// "List Number" styles work for legal/technical documents.
/// </summary>
/// <remarks>
/// When a Level has ParagraphStyleIdInLevel, any paragraph with that
/// style ID automatically gets numbered. The numbering is "linked" to
/// the style — you don't need NumberingProperties on each paragraph
/// (though it's also valid to add them explicitly).
/// </remarks>
public static void CreateOutlineNumbering(
NumberingDefinitionsPart numPart,
StyleDefinitionsPart stylesPart)
{
int abstractNumId = 3;
int numId = 4;
var abstractNum = new AbstractNum(
// Level 0: "1" — linked to Heading1
new Level(
new StartNumberingValue { Val = 1 },
new NumberingFormat { Val = NumberFormatValues.Decimal },
new LevelText { Val = "%1" },
new LevelJustification { Val = LevelJustificationValues.Left },
new ParagraphStyleIdInLevel { Val = "Heading1" },
new PreviousParagraphProperties(
new Indentation { Left = "432", Hanging = "432" })
)
{ LevelIndex = 0 },
// Level 1: "1.1" — linked to Heading2
new Level(
new StartNumberingValue { Val = 1 },
new NumberingFormat { Val = NumberFormatValues.Decimal },
new LevelText { Val = "%1.%2" },
new LevelJustification { Val = LevelJustificationValues.Left },
new ParagraphStyleIdInLevel { Val = "Heading2" },
new PreviousParagraphProperties(
new Indentation { Left = "576", Hanging = "576" })
)
{ LevelIndex = 1 },
// Level 2: "1.1.1" — linked to Heading3
new Level(
new StartNumberingValue { Val = 1 },
new NumberingFormat { Val = NumberFormatValues.Decimal },
new LevelText { Val = "%1.%2.%3" },
new LevelJustification { Val = LevelJustificationValues.Left },
new ParagraphStyleIdInLevel { Val = "Heading3" },
new PreviousParagraphProperties(
new Indentation { Left = "720", Hanging = "720" })
)
{ LevelIndex = 2 }
)
{
AbstractNumberId = abstractNumId,
// MultiLevelType controls how Word treats level transitions:
// - HybridMultilevel: each level is somewhat independent (most common)
// - Multilevel: true outline numbering where sub-levels nest under parents
// - SingleLevel: only one level
MultiLevelType = new MultiLevelType
{
Val = MultiLevelValues.Multilevel
}
};
// Ensure AbstractNum appears first, then NumberingInstance
EnsureNumberingRoot(numPart);
numPart.Numbering.Append(abstractNum);
var numInstance = new NumberingInstance(
new AbstractNumId { Val = abstractNumId })
{ NumberID = numId };
numPart.Numbering.Append(numInstance);
// Link the styles to the numbering definition.
// Each heading style gets a NumberingProperties pointing to this numId.
Styles styles = stylesPart.Styles ?? (stylesPart.Styles = new Styles());
LinkStyleToNumbering(styles, "Heading1", numId, level: 0);
LinkStyleToNumbering(styles, "Heading2", numId, level: 1);
LinkStyleToNumbering(styles, "Heading3", numId, level: 2);
}
// ── 5. Legal Numbering ─────────────────────────────────────────────
/// <summary>
/// Creates a legal document numbering pattern:
/// Article I, Article II (Roman numerals)
/// Section 1, Section 2 (Decimal)
/// (a), (b), (c) (Lowercase letters)
/// </summary>
public static void CreateLegalNumbering(
NumberingDefinitionsPart numPart, Body body)
{
int abstractNumId = 4;
int numId = 5;
var abstractNum = new AbstractNum(
// Level 0: "Article I" — Upper Roman
new Level(
new StartNumberingValue { Val = 1 },
new NumberingFormat { Val = NumberFormatValues.UpperRoman },
new LevelText { Val = "Article %1" },
new LevelJustification { Val = LevelJustificationValues.Left },
new PreviousParagraphProperties(
new Indentation { Left = "720", Hanging = "720" }),
new NumberingSymbolRunProperties(
new Bold(),
new RunFonts { Ascii = "Times New Roman", HighAnsi = "Times New Roman" })
)
{ LevelIndex = 0 },
// Level 1: "Section 1" — Decimal
new Level(
new StartNumberingValue { Val = 1 },
new NumberingFormat { Val = NumberFormatValues.Decimal },
new LevelText { Val = "Section %2" },
new LevelJustification { Val = LevelJustificationValues.Left },
new PreviousParagraphProperties(
new Indentation { Left = "1440", Hanging = "720" })
)
{ LevelIndex = 1 },
// Level 2: "(a)" — Lowercase letter
new Level(
new StartNumberingValue { Val = 1 },
new NumberingFormat { Val = NumberFormatValues.LowerLetter },
new LevelText { Val = "(%3)" },
new LevelJustification { Val = LevelJustificationValues.Left },
new PreviousParagraphProperties(
new Indentation { Left = "2160", Hanging = "720" })
)
{ LevelIndex = 2 }
)
{
AbstractNumberId = abstractNumId,
MultiLevelType = new MultiLevelType { Val = MultiLevelValues.Multilevel }
};
EnsureNumberingRoot(numPart);
numPart.Numbering.Append(abstractNum);
SetupNumberingInstance(numPart, numId, abstractNumId);
// Sample legal document structure
body.AppendChild(CreateListParagraph("Definitions", numId, level: 0));
body.AppendChild(CreateListParagraph("General Terms", numId, level: 1));
body.AppendChild(CreateListParagraph(
"\"Agreement\" means this document and all exhibits.", numId, level: 2));
body.AppendChild(CreateListParagraph(
"\"Party\" means any signatory to this Agreement.", numId, level: 2));
body.AppendChild(CreateListParagraph("Scope of Work", numId, level: 1));
body.AppendChild(CreateListParagraph("Obligations", numId, level: 0));
}
// ── 6. Chinese Numbering ───────────────────────────────────────────
/// <summary>
/// Creates a Chinese document numbering hierarchy:
/// Level 0: 一、二、三、 (Chinese ideographic, followed by 、)
/// Level 1: (一)(二)(三) (Chinese ideographic in parentheses)
/// Level 2: 1. 2. 3. (Decimal, Arabic numerals)
/// Level 3: (1) (2) (3) (Decimal in parentheses)
///
/// Chinese numbering uses NumberFormatValues.ChineseCounting or
/// ChineseCountingThousand for 一二三 style characters.
/// The font for Chinese number characters should be a CJK font like SimSun or SimHei.
/// </summary>
public static void CreateChineseNumbering(
NumberingDefinitionsPart numPart, Body body)
{
int abstractNumId = 5;
int numId = 6;
var abstractNum = new AbstractNum(
// Level 0: 一、 二、 三、
// ChineseCountingThousand produces 一 二 三 四 五 六 七 八 九 十
new Level(
new StartNumberingValue { Val = 1 },
new NumberingFormat { Val = NumberFormatValues.ChineseCountingThousand },
new LevelText { Val = "%1\u3001" }, // 、 is the Chinese enumeration comma
new LevelJustification { Val = LevelJustificationValues.Left },
new PreviousParagraphProperties(
new Indentation { Left = "840", Hanging = "420" }),
// NumberingSymbolRunProperties MUST specify a CJK font
// so the Chinese number renders correctly
new NumberingSymbolRunProperties(
new RunFonts
{
Ascii = "SimSun",
HighAnsi = "SimSun",
EastAsia = "SimSun", // Critical for CJK rendering
ComplexScript = "SimSun"
})
)
{ LevelIndex = 0 },
// Level 1: (一)(二)(三)
new Level(
new StartNumberingValue { Val = 1 },
new NumberingFormat { Val = NumberFormatValues.ChineseCountingThousand },
new LevelText { Val = "\uFF08%2\uFF09" }, // and are fullwidth parens
new LevelJustification { Val = LevelJustificationValues.Left },
new PreviousParagraphProperties(
new Indentation { Left = "1260", Hanging = "420" }),
new NumberingSymbolRunProperties(
new RunFonts
{
Ascii = "SimSun",
HighAnsi = "SimSun",
EastAsia = "SimSun",
ComplexScript = "SimSun"
})
)
{ LevelIndex = 1 },
// Level 2: 1. 2. 3.
new Level(
new StartNumberingValue { Val = 1 },
new NumberingFormat { Val = NumberFormatValues.Decimal },
new LevelText { Val = "%3." },
new LevelJustification { Val = LevelJustificationValues.Left },
new PreviousParagraphProperties(
new Indentation { Left = "1680", Hanging = "420" })
)
{ LevelIndex = 2 },
// Level 3: (1) (2) (3)
new Level(
new StartNumberingValue { Val = 1 },
new NumberingFormat { Val = NumberFormatValues.Decimal },
new LevelText { Val = "(%4)" },
new LevelJustification { Val = LevelJustificationValues.Left },
new PreviousParagraphProperties(
new Indentation { Left = "2100", Hanging = "420" })
)
{ LevelIndex = 3 }
)
{
AbstractNumberId = abstractNumId,
MultiLevelType = new MultiLevelType { Val = MultiLevelValues.Multilevel }
};
EnsureNumberingRoot(numPart);
numPart.Numbering.Append(abstractNum);
SetupNumberingInstance(numPart, numId, abstractNumId);
body.AppendChild(CreateListParagraph("总则", numId, level: 0));
body.AppendChild(CreateListParagraph("目的和依据", numId, level: 1));
body.AppendChild(CreateListParagraph("本办法适用于全体员工。", numId, level: 2));
body.AppendChild(CreateListParagraph("自发布之日起施行。", numId, level: 3));
body.AppendChild(CreateListParagraph("适用范围", numId, level: 1));
body.AppendChild(CreateListParagraph("职责与权限", numId, level: 0));
}
// ── 7. Restart Numbering ───────────────────────────────────────────
/// <summary>
/// Demonstrates how to restart a numbered list at 1 using LevelOverride
/// with StartOverride. This creates a new NumberingInstance that shares
/// the same AbstractNum but overrides the start value.
/// </summary>
/// <remarks>
/// Scenario: You have items 1-5 in one list, then want a separate list
/// that starts again at 1 with the same formatting. You need a new
/// NumberingInstance (new NumId) with LevelOverride.
/// </remarks>
public static void RestartNumbering(
NumberingDefinitionsPart numPart, Body body)
{
int abstractNumId = 6;
int numId1 = 7;
int numId2 = 8; // Second instance for restarted list
// Simple single-level numbered list
var levels = new Level[]
{
CreateNumberLevel(
levelIndex: 0,
format: NumberFormatValues.Decimal,
levelText: "%1.",
indentLeftDxa: 720,
hangingDxa: 360,
start: 1)
};
SetupAbstractNum(numPart, abstractNumId, levels);
SetupNumberingInstance(numPart, numId1, abstractNumId);
// First list: 1, 2, 3
body.AppendChild(CreateListParagraph("First list item 1", numId1, level: 0));
body.AppendChild(CreateListParagraph("First list item 2", numId1, level: 0));
body.AppendChild(CreateListParagraph("First list item 3", numId1, level: 0));
// Non-list paragraph between the lists
body.AppendChild(new Paragraph(
new Run(new Text("Some text between lists."))));
// Create a NEW NumberingInstance with LevelOverride to restart at 1.
// LevelOverride on a NumberingInstance overrides a specific level's
// start value WITHOUT creating a new AbstractNum.
var restartedInstance = new NumberingInstance(
new AbstractNumId { Val = abstractNumId },
// LevelOverride resets level 0 to start at 1
new LevelOverride(
new StartOverrideNumberingValue { Val = 1 }
)
{ LevelIndex = 0 }
)
{ NumberID = numId2 };
numPart.Numbering.Append(restartedInstance);
// Second list uses numId2: starts at 1 again
body.AppendChild(CreateListParagraph("Restarted item 1", numId2, level: 0));
body.AppendChild(CreateListParagraph("Restarted item 2", numId2, level: 0));
body.AppendChild(CreateListParagraph("Restarted item 3", numId2, level: 0));
}
// ── 8. Continue Numbering ──────────────────────────────────────────
/// <summary>
/// Continues numbering from a previous list by using the same NumId.
/// All paragraphs sharing a NumId form a single continuous sequence.
/// Inserting non-list paragraphs between them does NOT break the sequence.
/// </summary>
/// <param name="body">The Body to append paragraphs to.</param>
/// <param name="existingNumId">The NumId of the list to continue.</param>
public static void ContinueNumbering(Body body, int existingNumId)
{
// Simply use the SAME numId as the existing list.
// Word automatically continues the counter from wherever it left off.
// Even if there are non-list paragraphs in between, the numbering
// picks up seamlessly.
body.AppendChild(new Paragraph(
new Run(new Text("(Non-list paragraph — numbering continues after this.)"))));
// These will be numbered 4, 5 (assuming previous list ended at 3)
body.AppendChild(CreateListParagraph(
"Continued item", existingNumId, level: 0));
body.AppendChild(CreateListParagraph(
"Another continued item", existingNumId, level: 0));
}
// ── 9. Setup AbstractNum (Helper) ──────────────────────────────────
/// <summary>
/// Builds an AbstractNum from an array of Level definitions and appends
/// it to the Numbering root. AbstractNum defines the *format* of a list
/// (bullet characters, number format, indentation, fonts).
/// </summary>
/// <param name="numPart">The NumberingDefinitionsPart to append to.</param>
/// <param name="abstractNumId">Unique ID for this abstract definition.</param>
/// <param name="levels">Array of Level elements (one per nesting level, max 9).</param>
public static void SetupAbstractNum(
NumberingDefinitionsPart numPart, int abstractNumId, Level[] levels)
{
EnsureNumberingRoot(numPart);
var abstractNum = new AbstractNum
{
AbstractNumberId = abstractNumId,
// MultiLevelType:
// HybridMultilevel — most common; each level can have independent formatting
// Multilevel — true outline; sub-levels inherit parent context
// SingleLevel — only level 0 is used
MultiLevelType = new MultiLevelType
{
Val = levels.Length > 1
? MultiLevelValues.HybridMultilevel
: MultiLevelValues.SingleLevel
}
};
foreach (Level level in levels)
{
abstractNum.Append(level.CloneNode(true));
}
// IMPORTANT: AbstractNum must be inserted BEFORE any NumberingInstance
// elements in the Numbering root. Find the right position.
NumberingInstance? firstNumInstance =
numPart.Numbering.GetFirstChild<NumberingInstance>();
if (firstNumInstance is not null)
{
numPart.Numbering.InsertBefore(abstractNum, firstNumInstance);
}
else
{
numPart.Numbering.Append(abstractNum);
}
}
// ── 10. Setup NumberingInstance (Helper) ────────────────────────────
/// <summary>
/// Creates a NumberingInstance (Num element) that references an AbstractNum.
/// The NumberingInstance is what paragraphs actually point to via NumId.
/// Multiple paragraphs with the same NumId form one continuous list.
/// </summary>
/// <param name="numPart">The NumberingDefinitionsPart to append to.</param>
/// <param name="numId">Unique instance ID (referenced by paragraphs).
/// Must be &gt;= 1; value 0 is reserved for "no numbering".</param>
/// <param name="abstractNumId">The AbstractNum this instance uses.</param>
public static void SetupNumberingInstance(
NumberingDefinitionsPart numPart, int numId, int abstractNumId)
{
EnsureNumberingRoot(numPart);
// NumberingInstance (w:num) links to AbstractNum via AbstractNumId child
var numInstance = new NumberingInstance(
new AbstractNumId { Val = abstractNumId })
{
// NumberID is the w:numId attribute; this is what paragraphs reference
NumberID = numId
};
// NumberingInstance MUST come after all AbstractNum elements
numPart.Numbering.Append(numInstance);
}
// ── 11. Apply Numbering to Paragraph (Helper) ──────────────────────
/// <summary>
/// Applies numbering to an existing paragraph by setting NumberingProperties
/// in the ParagraphProperties. This is the final link that makes a
/// paragraph display as a list item.
/// </summary>
/// <param name="para">The paragraph to make into a list item.</param>
/// <param name="numId">The NumberingInstance ID to use.</param>
/// <param name="level">The indentation level (0 = top level, max 8).</param>
public static void ApplyNumberingToParagraph(Paragraph para, int numId, int level)
{
// NumberingProperties contains:
// - NumberingLevelReference (w:ilvl) — which level (0-8)
// - NumberingId (w:numId) — which NumberingInstance to use
var numberingProperties = new NumberingProperties(
new NumberingLevelReference { Val = level },
new NumberingId { Val = numId });
// Ensure ParagraphProperties exists
ParagraphProperties pPr = para.GetFirstChild<ParagraphProperties>()
?? para.PrependChild(new ParagraphProperties());
// Replace existing NumberingProperties if present
NumberingProperties? existing = pPr.GetFirstChild<NumberingProperties>();
if (existing is not null)
{
pPr.ReplaceChild(numberingProperties, existing);
}
else
{
// NumberingProperties should appear early in ParagraphProperties
// (after ParagraphStyleId if present)
ParagraphStyleId? styleId = pPr.GetFirstChild<ParagraphStyleId>();
if (styleId is not null)
{
pPr.InsertAfter(numberingProperties, styleId);
}
else
{
pPr.PrependChild(numberingProperties);
}
}
}
// ── Private Helper Methods ─────────────────────────────────────────
/// <summary>
/// Creates a bullet-type Level definition.
/// </summary>
private static Level CreateBulletLevel(
int levelIndex,
string bulletChar,
string font,
int indentLeftDxa,
int hangingDxa)
{
return new Level(
// Bullets don't increment, but StartNumberingValue is still required
new StartNumberingValue { Val = 1 },
// NumberFormatValues.Bullet tells Word this is a bullet, not a number
new NumberingFormat { Val = NumberFormatValues.Bullet },
// LevelText.Val is the actual bullet character
new LevelText { Val = bulletChar },
new LevelJustification { Val = LevelJustificationValues.Left },
// PreviousParagraphProperties controls indentation of the text
// (confusingly named; it's the paragraph indent for THIS level)
new PreviousParagraphProperties(
new Indentation
{
Left = indentLeftDxa.ToString(),
Hanging = hangingDxa.ToString()
}),
// NumberingSymbolRunProperties sets the font for the bullet character.
// Without this, the bullet renders in the paragraph's body font,
// which may not contain the glyph (e.g., Symbol characters).
new NumberingSymbolRunProperties(
new RunFonts
{
Ascii = font,
HighAnsi = font,
Hint = FontTypeHintValues.Default
})
)
{ LevelIndex = levelIndex };
}
/// <summary>
/// Creates a number-type Level definition.
/// </summary>
private static Level CreateNumberLevel(
int levelIndex,
NumberFormatValues format,
string levelText,
int indentLeftDxa,
int hangingDxa,
int start)
{
return new Level(
new StartNumberingValue { Val = start },
new NumberingFormat { Val = format },
new LevelText { Val = levelText },
new LevelJustification { Val = LevelJustificationValues.Left },
new PreviousParagraphProperties(
new Indentation
{
Left = indentLeftDxa.ToString(),
Hanging = hangingDxa.ToString()
})
)
{ LevelIndex = levelIndex };
}
/// <summary>
/// Creates a paragraph with text and numbering properties applied.
/// </summary>
private static Paragraph CreateListParagraph(string text, int numId, int level)
{
var para = new Paragraph(
new ParagraphProperties(
new NumberingProperties(
new NumberingLevelReference { Val = level },
new NumberingId { Val = numId })),
new Run(new Text(text)));
return para;
}
/// <summary>
/// Ensures the Numbering root element exists on the NumberingDefinitionsPart.
/// </summary>
private static void EnsureNumberingRoot(NumberingDefinitionsPart numPart)
{
if (numPart.Numbering is null)
{
numPart.Numbering = new Numbering();
}
}
/// <summary>
/// Links a named style to a numbering definition by adding NumberingProperties
/// to the style's ParagraphProperties.
/// </summary>
private static void LinkStyleToNumbering(
Styles styles, string styleId, int numId, int level)
{
// Find existing style or create it
Style? style = styles.Elements<Style>()
.FirstOrDefault(s => s.StyleId?.Value == styleId);
if (style is null)
{
style = new Style
{
Type = StyleValues.Paragraph,
StyleId = styleId,
StyleName = new StyleName { Val = styleId }
};
styles.Append(style);
}
// Ensure StyleParagraphProperties exists
StyleParagraphProperties? spPr = style.GetFirstChild<StyleParagraphProperties>();
if (spPr is null)
{
spPr = new StyleParagraphProperties();
style.Append(spPr);
}
// Set NumberingProperties on the style
NumberingProperties? existingNumPr = spPr.GetFirstChild<NumberingProperties>();
var newNumPr = new NumberingProperties(
new NumberingLevelReference { Val = level },
new NumberingId { Val = numId });
if (existingNumPr is not null)
{
spPr.ReplaceChild(newNumPr, existingNumPr);
}
else
{
spPr.Append(newNumPr);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,595 @@
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace MiniMaxAIDocx.Core.Samples;
/// <summary>
/// Reference implementations for revision tracking (Track Changes).
///
/// ╔══════════════════════════════════════════════════════════════════╗
/// ║ CRITICAL: w:del uses w:delText, NEVER w:t ║
/// ║ w:ins uses w:t, NEVER w:delText ║
/// ║ Getting this wrong silently corrupts the document. ║
/// ║ Word will open without error but display garbled text or ║
/// ║ lose content when accepting/rejecting changes. ║
/// ╚══════════════════════════════════════════════════════════════════╝
///
/// KEY CONCEPTS:
/// - Every revision element (ins, del, rPrChange, pPrChange) needs:
/// w:id — unique revision ID (string, must be unique across all revisions)
/// w:author — who made the change
/// w:date — ISO 8601 timestamp
/// - InsertedRun (w:ins) wraps normal Run elements with w:t text
/// - DeletedRun (w:del) wraps Run elements that use DeletedText (w:delText) instead of Text (w:t)
/// - MoveFrom/MoveTo track text that was moved (not just deleted+inserted)
/// </summary>
public static class TrackChangesSamples
{
/// <summary>
/// Thread-safe counter for generating unique revision IDs.
/// In production, scan the document for the max existing ID first.
/// </summary>
private static int s_revisionCounter;
// ──────────────────────────────────────────────
// 1. EnableTrackChanges
// ──────────────────────────────────────────────
/// <summary>
/// Enables revision tracking in the document settings.
/// This makes Word record all subsequent edits as tracked changes.
///
/// Maps to: &lt;w:trackChanges/&gt; in settings.xml
///
/// Note: This only controls whether NEW edits are tracked.
/// Existing revision marks are always preserved regardless of this setting.
/// </summary>
public static void EnableTrackChanges(DocumentSettingsPart settingsPart)
{
settingsPart.Settings ??= new Settings();
var existing = settingsPart.Settings.GetFirstChild<TrackRevisions>();
if (existing == null)
{
settingsPart.Settings.Append(new TrackRevisions());
}
settingsPart.Settings.Save();
}
// ──────────────────────────────────────────────
// 2. InsertTrackedInsertion — w:ins with w:t
// ──────────────────────────────────────────────
/// <summary>
/// Inserts text as a tracked insertion (w:ins).
///
/// ╔══════════════════════════════════════════════════════╗
/// ║ w:ins uses w:t (Text), NOT w:delText. ║
/// ║ The text appears with green underline in Word. ║
/// ╚══════════════════════════════════════════════════════╝
///
/// XML structure:
/// &lt;w:ins w:id="1" w:author="John" w:date="2026-03-22T00:00:00Z"&gt;
/// &lt;w:r&gt;
/// &lt;w:t&gt;inserted text&lt;/w:t&gt; &lt;!-- w:t, NOT w:delText --&gt;
/// &lt;/w:r&gt;
/// &lt;/w:ins&gt;
/// </summary>
public static InsertedRun InsertTrackedInsertion(Paragraph para, string text, string author)
{
var ins = new InsertedRun
{
Id = GenerateRevisionId(),
Author = author,
Date = DateTime.UtcNow
};
// CORRECT: w:ins contains w:r with w:t (normal Text element)
ins.Append(new Run(
new Text(text) { Space = SpaceProcessingModeValues.Preserve }));
para.Append(ins);
return ins;
}
// ──────────────────────────────────────────────
// 3. InsertTrackedDeletion — w:del with w:delText
// ──────────────────────────────────────────────
/// <summary>
/// Inserts text as a tracked deletion (w:del).
///
/// ╔══════════════════════════════════════════════════════╗
/// ║ w:del uses w:delText (DeletedText), NOT w:t. ║
/// ║ Using w:t inside w:del SILENTLY CORRUPTS the file. ║
/// ║ The text appears with red strikethrough in Word. ║
/// ╚══════════════════════════════════════════════════════╝
///
/// XML structure:
/// &lt;w:del w:id="2" w:author="John" w:date="2026-03-22T00:00:00Z"&gt;
/// &lt;w:r&gt;
/// &lt;w:delText xml:space="preserve"&gt;deleted text&lt;/w:delText&gt; &lt;!-- w:delText, NOT w:t --&gt;
/// &lt;/w:r&gt;
/// &lt;/w:del&gt;
/// </summary>
public static DeletedRun InsertTrackedDeletion(Paragraph para, string deletedText, string author)
{
var del = new DeletedRun
{
Id = GenerateRevisionId(),
Author = author,
Date = DateTime.UtcNow
};
// CORRECT: w:del contains w:r with w:delText (DeletedText element)
// WRONG would be: new Text(deletedText) — this creates w:t which corrupts the document
del.Append(new Run(
new DeletedText(deletedText) { Space = SpaceProcessingModeValues.Preserve }));
para.Append(del);
return del;
}
// ──────────────────────────────────────────────
// 4. InsertFormattingChange — RunPropertiesChange
// ──────────────────────────────────────────────
/// <summary>
/// Records a formatting change on a run (e.g., text was made bold).
///
/// RunPropertiesChange (w:rPrChange) stores the PREVIOUS formatting.
/// The current RunProperties on the run reflects the NEW formatting.
///
/// Example: text changed from normal to bold:
/// &lt;w:rPr&gt;
/// &lt;w:b/&gt; &lt;!-- current: bold --&gt;
/// &lt;w:rPrChange w:id="3" w:author="John" w:date="..."&gt;
/// &lt;w:rPr/&gt; &lt;!-- previous: no bold --&gt;
/// &lt;/w:rPrChange&gt;
/// &lt;/w:rPr&gt;
/// </summary>
public static void InsertFormattingChange(Run run, string author)
{
// Ensure RunProperties exists
run.RunProperties ??= new RunProperties();
// Store the previous (empty/normal) formatting as the "before" state
var rPrChange = new RunPropertiesChange
{
Id = GenerateRevisionId(),
Author = author,
Date = DateTime.UtcNow
};
// The child RunProperties inside rPrChange is the OLD formatting (before the change).
// An empty RunProperties means "was default/normal formatting."
rPrChange.Append(new PreviousRunProperties());
run.RunProperties.Append(rPrChange);
}
// ──────────────────────────────────────────────
// 5. InsertParagraphFormatChange — ParagraphPropertiesChange
// ──────────────────────────────────────────────
/// <summary>
/// Records a paragraph formatting change (e.g., alignment changed).
///
/// ParagraphPropertiesChange (w:pPrChange) stores the PREVIOUS paragraph properties.
/// The current ParagraphProperties reflects the NEW formatting.
///
/// Example: paragraph changed from left-aligned to centered:
/// &lt;w:pPr&gt;
/// &lt;w:jc w:val="center"/&gt; &lt;!-- current: centered --&gt;
/// &lt;w:pPrChange w:id="4" w:author="John" w:date="..."&gt;
/// &lt;w:pPr&gt;
/// &lt;w:jc w:val="left"/&gt; &lt;!-- previous: left --&gt;
/// &lt;/w:pPr&gt;
/// &lt;/w:pPrChange&gt;
/// &lt;/w:pPr&gt;
/// </summary>
public static void InsertParagraphFormatChange(Paragraph para, string author)
{
para.ParagraphProperties ??= new ParagraphProperties();
var pPrChange = new ParagraphPropertiesChange
{
Id = GenerateRevisionId(),
Author = author,
Date = DateTime.UtcNow
};
// Store previous paragraph properties (before the change)
// Example: was left-aligned before changing to whatever the current alignment is
var previousPPr = new ParagraphPropertiesExtended();
previousPPr.Append(new Justification { Val = JustificationValues.Left });
pPrChange.Append(previousPPr);
para.ParagraphProperties.Append(pPrChange);
}
// ──────────────────────────────────────────────
// 6. InsertTableRowInsertion — table revision marks
// ──────────────────────────────────────────────
/// <summary>
/// Marks a table row as a tracked insertion.
///
/// Table-level track changes use TableRowProperties with InsertedMathControl
/// mapped from w:trPr/w:ins — indicating the entire row was inserted.
///
/// Structure:
/// &lt;w:tr&gt;
/// &lt;w:trPr&gt;
/// &lt;w:ins w:id="5" w:author="John" w:date="..."/&gt;
/// &lt;/w:trPr&gt;
/// &lt;w:tc&gt;...&lt;/w:tc&gt;
/// &lt;/w:tr&gt;
/// </summary>
public static void InsertTableRowInsertion(TableRow row, string author)
{
row.TableRowProperties ??= new TableRowProperties();
var inserted = new Inserted
{
Id = GenerateRevisionId(),
Author = author,
Date = DateTime.UtcNow
};
row.TableRowProperties.Append(inserted);
}
// ──────────────────────────────────────────────
// 7. AcceptAllRevisions — accept all tracked changes
// ──────────────────────────────────────────────
/// <summary>
/// Programmatically accepts all tracked changes in the document body.
///
/// For insertions (w:ins): unwrap the content (keep the runs, remove the w:ins wrapper)
/// For deletions (w:del): remove the entire element (the deleted text disappears)
/// For formatting changes: remove the rPrChange/pPrChange (keep new formatting)
/// For table row insertions: remove the w:ins from trPr
///
/// ╔══════════════════════════════════════════════════════════════╗
/// ║ Process deletions before insertions to avoid invalidating ║
/// ║ element references. Always call .ToList() before ║
/// ║ iterating to avoid modifying the collection during ║
/// ║ enumeration. ║
/// ╚══════════════════════════════════════════════════════════════╝
/// </summary>
public static void AcceptAllRevisions(Body body)
{
// 1. Accept deletions — remove the w:del and all its content
foreach (var del in body.Descendants<DeletedRun>().ToList())
{
del.Remove();
}
// 2. Accept insertions — unwrap w:ins, keeping child runs in place
foreach (var ins in body.Descendants<InsertedRun>().ToList())
{
var parent = ins.Parent;
if (parent == null) continue;
// Move all child elements before the ins element, then remove ins
var children = ins.ChildElements.ToList();
foreach (var child in children)
{
child.Remove();
ins.InsertBeforeSelf(child);
}
ins.Remove();
}
// 3. Accept formatting changes — remove rPrChange (keep new formatting)
foreach (var rPrChange in body.Descendants<RunPropertiesChange>().ToList())
{
rPrChange.Remove();
}
// 4. Accept paragraph formatting changes
foreach (var pPrChange in body.Descendants<ParagraphPropertiesChange>().ToList())
{
pPrChange.Remove();
}
// 5. Accept table row insertions — remove w:ins from trPr
foreach (var inserted in body.Descendants<TableRowProperties>()
.SelectMany(trPr => trPr.Elements<Inserted>()).ToList())
{
inserted.Remove();
}
// 6. Accept MoveFrom/MoveTo — keep MoveTo content, remove MoveFrom
foreach (var moveFrom in body.Descendants<MoveFromRun>().ToList())
{
moveFrom.Remove();
}
foreach (var moveTo in body.Descendants<MoveToRun>().ToList())
{
var parent = moveTo.Parent;
if (parent == null) continue;
var children = moveTo.ChildElements.ToList();
foreach (var child in children)
{
child.Remove();
moveTo.InsertBeforeSelf(child);
}
moveTo.Remove();
}
// 7. Remove move range markers
foreach (var marker in body.Descendants<MoveFromRangeStart>().ToList()) marker.Remove();
foreach (var marker in body.Descendants<MoveFromRangeEnd>().ToList()) marker.Remove();
foreach (var marker in body.Descendants<MoveToRangeStart>().ToList()) marker.Remove();
foreach (var marker in body.Descendants<MoveToRangeEnd>().ToList()) marker.Remove();
}
// ──────────────────────────────────────────────
// 8. RejectAllRevisions — reject all tracked changes
// ──────────────────────────────────────────────
/// <summary>
/// Programmatically rejects all tracked changes in the document body.
///
/// For insertions (w:ins): remove the entire element (the inserted text disappears)
/// For deletions (w:del): unwrap the content and convert w:delText back to w:t
/// (the "deleted" text is restored)
/// For formatting changes: restore old formatting from rPrChange/pPrChange
///
/// ╔══════════════════════════════════════════════════════════════╗
/// ║ When rejecting deletions, you MUST convert w:delText back ║
/// ║ to w:t. Leaving w:delText in a non-deleted run causes ║
/// ║ the text to be invisible in Word. ║
/// ╚══════════════════════════════════════════════════════════════╝
/// </summary>
public static void RejectAllRevisions(Body body)
{
// 1. Reject insertions — remove the entire w:ins and its content
foreach (var ins in body.Descendants<InsertedRun>().ToList())
{
ins.Remove();
}
// 2. Reject deletions — restore deleted text by unwrapping w:del
// and converting w:delText back to w:t
foreach (var del in body.Descendants<DeletedRun>().ToList())
{
var parent = del.Parent;
if (parent == null) continue;
// Convert DeletedText -> Text in each run inside the deletion
foreach (var run in del.Elements<Run>().ToList())
{
foreach (var delText in run.Elements<DeletedText>().ToList())
{
// IMPORTANT: convert w:delText back to w:t
var text = new Text(delText.Text ?? "") { Space = SpaceProcessingModeValues.Preserve };
delText.InsertAfterSelf(text);
delText.Remove();
}
}
// Unwrap — move children before the del element
var children = del.ChildElements.ToList();
foreach (var child in children)
{
child.Remove();
del.InsertBeforeSelf(child);
}
del.Remove();
}
// 3. Reject formatting changes — restore old RunProperties
foreach (var rPrChange in body.Descendants<RunPropertiesChange>().ToList())
{
var runProperties = rPrChange.Parent as RunProperties;
if (runProperties == null) continue;
// Get the previous (old) formatting
var previousRPr = rPrChange.GetFirstChild<PreviousRunProperties>();
if (previousRPr != null)
{
// Remove current formatting (except the rPrChange itself)
var currentProps = runProperties.ChildElements
.Where(c => c is not RunPropertiesChange).ToList();
foreach (var prop in currentProps)
{
prop.Remove();
}
// Restore old formatting from PreviousRunProperties
foreach (var oldProp in previousRPr.ChildElements.ToList())
{
oldProp.Remove();
runProperties.Append(oldProp);
}
}
rPrChange.Remove();
}
// 4. Reject paragraph formatting changes — restore old ParagraphProperties
foreach (var pPrChange in body.Descendants<ParagraphPropertiesChange>().ToList())
{
var paragraphProperties = pPrChange.Parent as ParagraphProperties;
if (paragraphProperties == null) continue;
var previousPPr = pPrChange.GetFirstChild<ParagraphPropertiesExtended>();
if (previousPPr != null)
{
var currentProps = paragraphProperties.ChildElements
.Where(c => c is not ParagraphPropertiesChange).ToList();
foreach (var prop in currentProps)
{
prop.Remove();
}
foreach (var oldProp in previousPPr.ChildElements.ToList())
{
oldProp.Remove();
paragraphProperties.Append(oldProp);
}
}
pPrChange.Remove();
}
// 5. Reject table row insertions — remove the entire row
foreach (var row in body.Descendants<TableRow>().ToList())
{
var trPr = row.TableRowProperties;
if (trPr?.GetFirstChild<Inserted>() != null)
{
row.Remove();
}
}
// 6. Reject MoveFrom/MoveTo — keep MoveFrom content (original position), remove MoveTo
foreach (var moveTo in body.Descendants<MoveToRun>().ToList())
{
moveTo.Remove();
}
foreach (var moveFrom in body.Descendants<MoveFromRun>().ToList())
{
var parent = moveFrom.Parent;
if (parent == null) continue;
// Convert any DeletedText back to Text in MoveFrom runs
foreach (var run in moveFrom.Elements<Run>().ToList())
{
foreach (var delText in run.Elements<DeletedText>().ToList())
{
var text = new Text(delText.Text ?? "") { Space = SpaceProcessingModeValues.Preserve };
delText.InsertAfterSelf(text);
delText.Remove();
}
}
var children = moveFrom.ChildElements.ToList();
foreach (var child in children)
{
child.Remove();
moveFrom.InsertBeforeSelf(child);
}
moveFrom.Remove();
}
// 7. Remove move range markers
foreach (var marker in body.Descendants<MoveFromRangeStart>().ToList()) marker.Remove();
foreach (var marker in body.Descendants<MoveFromRangeEnd>().ToList()) marker.Remove();
foreach (var marker in body.Descendants<MoveToRangeStart>().ToList()) marker.Remove();
foreach (var marker in body.Descendants<MoveToRangeEnd>().ToList()) marker.Remove();
}
// ──────────────────────────────────────────────
// 9. InsertMoveFromTo — MoveFrom + MoveTo blocks
// ──────────────────────────────────────────────
/// <summary>
/// Creates a tracked move operation (text moved from one location to another).
///
/// A move consists of:
/// - MoveFromRangeStart/End markers around the original location
/// - MoveFrom (w:moveFrom) containing the original text with w:delText
/// - MoveToRangeStart/End markers around the new location
/// - MoveTo (w:moveTo) containing the moved text with w:t
/// - Both share the same name attribute to link them
///
/// ╔══════════════════════════════════════════════════════════════╗
/// ║ MoveFrom uses w:delText (like w:del — text is "leaving") ║
/// ║ MoveTo uses w:t (like w:ins — text is "arriving") ║
/// ╚══════════════════════════════════════════════════════════════╝
/// </summary>
public static void InsertMoveFromTo(Body body, string movedText, string author)
{
string moveId = GenerateRevisionId();
string moveId2 = GenerateRevisionId();
string moveName = "move" + moveId;
// ── MoveFrom paragraph (original location — text shown with strikethrough) ──
var moveFromPara = new Paragraph();
moveFromPara.Append(new MoveFromRangeStart
{
Id = moveId,
Author = author,
Date = DateTime.UtcNow,
Name = moveName
});
var moveFrom = new MoveFromRun
{
Id = GenerateRevisionId(),
Author = author,
Date = DateTime.UtcNow
};
// MoveFrom uses DeletedText (w:delText), NOT Text (w:t)
// The text is visually struck through in Word
moveFrom.Append(new Run(
new DeletedText(movedText) { Space = SpaceProcessingModeValues.Preserve }));
moveFromPara.Append(moveFrom);
moveFromPara.Append(new MoveFromRangeEnd { Id = moveId });
body.Append(moveFromPara);
// ── MoveTo paragraph (destination — text shown with double underline) ──
var moveToPara = new Paragraph();
moveToPara.Append(new MoveToRangeStart
{
Id = moveId2,
Author = author,
Date = DateTime.UtcNow,
Name = moveName
});
var moveTo = new MoveToRun
{
Id = GenerateRevisionId(),
Author = author,
Date = DateTime.UtcNow
};
// MoveTo uses Text (w:t), NOT DeletedText (w:delText)
// The text is visually double-underlined in green in Word
moveTo.Append(new Run(
new Text(movedText) { Space = SpaceProcessingModeValues.Preserve }));
moveToPara.Append(moveTo);
moveToPara.Append(new MoveToRangeEnd { Id = moveId2 });
body.Append(moveToPara);
}
// ──────────────────────────────────────────────
// 10. GenerateRevisionId — unique ID pattern
// ──────────────────────────────────────────────
/// <summary>
/// Generates a unique revision ID string.
///
/// Revision IDs (w:id) must be unique across ALL revision elements in the document:
/// ins, del, rPrChange, pPrChange, moveFrom, moveTo, table row ins/del, etc.
///
/// Word uses simple incrementing integers starting from 0.
/// When programmatically adding revisions to an existing document,
/// first scan for the maximum existing ID and start from there.
///
/// For new documents, a simple counter suffices.
/// For existing documents, use:
/// int maxId = body.Descendants()
/// .SelectMany(e => e.GetAttributes())
/// .Where(a => a.LocalName == "id")
/// .Select(a => int.TryParse(a.Value, out int v) ? v : 0)
/// .DefaultIfEmpty(0)
/// .Max();
/// </summary>
public static string GenerateRevisionId()
{
return Interlocked.Increment(ref s_revisionCounter).ToString();
}
}

View File

@@ -0,0 +1,39 @@
using DocumentFormat.OpenXml.Wordprocessing;
namespace MiniMaxAIDocx.Core.Typography;
/// <summary>
/// CJK mixed typography helpers for East Asian font and paragraph configuration.
/// </summary>
public static class CjkHelper
{
public const string DefaultSimplifiedChinese = "SimSun";
public const string DefaultJapanese = "MS Mincho";
public const string DefaultKorean = "Batang";
/// <summary>
/// Sets the East Asia font on run properties.
/// </summary>
public static void SetEastAsiaFont(RunProperties rPr, string fontName)
{
var fonts = rPr.RunFonts;
if (fonts == null)
{
fonts = new RunFonts();
rPr.RunFonts = fonts;
}
fonts.EastAsia = fontName;
}
/// <summary>
/// Configures CJK-appropriate paragraph properties.
/// </summary>
public static void ConfigureCjkParagraph(ParagraphProperties pPr)
{
// Enable word wrap for CJK
pPr.WordWrap = new WordWrap { Val = true };
// Allow auto space between CJK and Latin/numbers
pPr.AutoSpaceDE = new AutoSpaceDE { Val = true };
pPr.AutoSpaceDN = new AutoSpaceDN { Val = true };
}
}

View File

@@ -0,0 +1,24 @@
namespace MiniMaxAIDocx.Core.Typography;
public record FontConfig(
string BodyFont,
string HeadingFont,
double BodySize,
double Heading1Size,
double Heading2Size,
double Heading3Size,
double Heading4Size,
double Heading5Size,
double Heading6Size,
double LineSpacing);
/// <summary>
/// Default font configurations by document type.
/// </summary>
public static class FontDefaults
{
public static FontConfig Report => new("Calibri", "Calibri Light", 11.0, 26.0, 20.0, 16.0, 14.0, 12.0, 11.0, 1.15);
public static FontConfig Letter => new("Calibri", "Calibri", 11.0, 16.0, 14.0, 12.0, 11.0, 11.0, 11.0, 1.0);
public static FontConfig Memo => new("Arial", "Arial", 11.0, 16.0, 14.0, 12.0, 11.0, 11.0, 11.0, 1.15);
public static FontConfig Academic => new("Times New Roman", "Times New Roman", 12.0, 16.0, 14.0, 13.0, 12.0, 12.0, 12.0, 2.0);
}

View File

@@ -0,0 +1,20 @@
namespace MiniMaxAIDocx.Core.Typography;
public record PageSize(int WidthDxa, int HeightDxa);
public record MarginConfig(int TopDxa, int BottomDxa, int LeftDxa, int RightDxa);
/// <summary>
/// Standard page sizes and margin presets in DXA units.
/// </summary>
public static class PageSizes
{
public static PageSize Letter => new(12240, 15840); // 8.5 x 11 inches
public static PageSize A4 => new(11906, 16838); // 210 x 297 mm
public static PageSize Legal => new(12240, 20160); // 8.5 x 14 inches
public static PageSize A3 => new(16838, 23811); // 297 x 420 mm
public static PageSize A5 => new(8391, 11906); // 148 x 210 mm
public static MarginConfig StandardMargins => new(1440, 1440, 1440, 1440); // 1 inch all
public static MarginConfig NarrowMargins => new(720, 720, 720, 720); // 0.5 inch all
public static MarginConfig WideMargins => new(1440, 1440, 2160, 2160); // 1" top/bottom, 1.5" left/right
}

View File

@@ -0,0 +1,224 @@
using System.IO.Compression;
using System.Xml.Linq;
namespace MiniMaxAIDocx.Core.Validation;
public class BusinessRuleValidator
{
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
private static readonly XNamespace R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
private static readonly XNamespace WP = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
private static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main";
private const int MinMarginDxa = 360; // 0.25 inch
private const int MaxMarginDxa = 4320; // 3 inches
private const int MinBodyFontHps = 16; // 8pt
private const int MaxBodyFontHps = 144; // 72pt
private const int MinHeadingFontHps = 20; // 10pt
private const int MaxHeadingFontHps = 192; // 96pt
public ValidationResult Validate(string docxPath)
{
var result = new ValidationResult();
using var zip = ZipFile.OpenRead(docxPath);
var docEntry = zip.GetEntry("word/document.xml")
?? throw new InvalidOperationException("Missing word/document.xml");
var doc = LoadXml(docEntry);
var body = doc.Root?.Element(W + "body");
if (body == null)
{
result.Errors.Add(Error("Document has no body element"));
return result;
}
ValidateMargins(body, result);
ValidateFontSizes(body, result);
ValidateHeadingHierarchy(body, result);
ValidateTableColumnWidths(body, result);
ValidateRelationships(zip, doc, result);
ValidateComments(zip, result);
return result;
}
private void ValidateMargins(XElement body, ValidationResult result)
{
foreach (var sectPr in body.Descendants(W + "sectPr"))
{
var pgMar = sectPr.Element(W + "pgMar");
if (pgMar == null) continue;
foreach (var attr in new[] { "top", "bottom", "left", "right" })
{
var val = (string?)pgMar.Attribute(W + attr);
if (val != null && int.TryParse(val, out var dxa))
{
var absDxa = Math.Abs(dxa);
if (absDxa < MinMarginDxa)
result.Errors.Add(Error($"Margin '{attr}' is {absDxa} DXA ({absDxa / 1440.0:F2}\"), below minimum {MinMarginDxa} DXA"));
if (absDxa > MaxMarginDxa)
result.Warnings.Add(Warning($"Margin '{attr}' is {absDxa} DXA ({absDxa / 1440.0:F2}\"), above maximum {MaxMarginDxa} DXA"));
}
}
}
}
private void ValidateFontSizes(XElement body, ValidationResult result)
{
foreach (var p in body.Descendants(W + "p"))
{
var pStyle = p.Element(W + "pPr")?.Element(W + "pStyle")?.Attribute(W + "val")?.Value;
bool isHeading = pStyle?.StartsWith("Heading", StringComparison.OrdinalIgnoreCase) == true;
foreach (var rPr in p.Descendants(W + "rPr"))
{
var szEl = rPr.Element(W + "sz");
var val = (string?)szEl?.Attribute(W + "val");
if (val != null && int.TryParse(val, out var hps))
{
int min = isHeading ? MinHeadingFontHps : MinBodyFontHps;
int max = isHeading ? MaxHeadingFontHps : MaxBodyFontHps;
if (hps < min || hps > max)
result.Warnings.Add(Warning($"Font size {hps / 2.0}pt is outside {(isHeading ? "heading" : "body")} range ({min / 2}-{max / 2}pt)"));
}
}
}
}
private void ValidateHeadingHierarchy(XElement body, ValidationResult result)
{
int lastLevel = 0;
foreach (var p in body.Descendants(W + "p"))
{
var pStyle = p.Element(W + "pPr")?.Element(W + "pStyle")?.Attribute(W + "val")?.Value;
if (pStyle == null) continue;
int level = 0;
if (pStyle.StartsWith("Heading", StringComparison.OrdinalIgnoreCase))
{
var numPart = pStyle.AsSpan(7);
if (int.TryParse(numPart, out var parsed)) level = parsed;
}
if (level > 0)
{
if (lastLevel > 0 && level > lastLevel + 1)
result.Warnings.Add(Warning($"Heading level skips from {lastLevel} to {level} (missing Heading{lastLevel + 1})"));
lastLevel = level;
}
}
}
private void ValidateTableColumnWidths(XElement body, ValidationResult result)
{
var sectPr = body.Element(W + "sectPr");
if (sectPr == null) return;
var pgSz = sectPr.Element(W + "pgSz");
var pgMar = sectPr.Element(W + "pgMar");
if (pgSz == null || pgMar == null) return;
if (!int.TryParse((string?)pgSz.Attribute(W + "w"), out var pageWidth)) return;
int.TryParse((string?)pgMar.Attribute(W + "left"), out var marginLeft);
int.TryParse((string?)pgMar.Attribute(W + "right"), out var marginRight);
var contentWidth = pageWidth - marginLeft - marginRight;
int tableIndex = 0;
foreach (var tbl in body.Descendants(W + "tbl"))
{
tableIndex++;
var firstRow = tbl.Element(W + "tr");
if (firstRow == null) continue;
int totalWidth = 0;
foreach (var tc in firstRow.Elements(W + "tc"))
{
var tcW = tc.Element(W + "tcPr")?.Element(W + "tcW");
var w = (string?)tcW?.Attribute(W + "w");
if (w != null && int.TryParse(w, out var cellWidth))
totalWidth += cellWidth;
}
if (totalWidth > 0)
{
var tolerance = contentWidth * 0.02;
if (Math.Abs(totalWidth - contentWidth) > tolerance)
result.Warnings.Add(Warning($"Table {tableIndex}: column widths sum to {totalWidth} DXA but content width is {contentWidth} DXA"));
}
}
}
private void ValidateRelationships(ZipArchive zip, XDocument doc, ValidationResult result)
{
var relsEntry = zip.GetEntry("word/_rels/document.xml.rels");
if (relsEntry == null) return;
var relDoc = LoadXml(relsEntry);
var ns = relDoc.Root?.Name.Namespace ?? XNamespace.None;
var definedIds = new HashSet<string>();
foreach (var rel in relDoc.Descendants(ns + "Relationship"))
{
var id = (string?)rel.Attribute("Id");
if (id != null) definedIds.Add(id);
}
var referencedIds = new HashSet<string>();
foreach (var el in doc.Descendants())
{
var rid = (string?)el.Attribute(R + "id") ?? (string?)el.Attribute(R + "embed");
if (rid != null) referencedIds.Add(rid);
}
foreach (var id in referencedIds.Except(definedIds))
result.Errors.Add(Error($"Reference r:id='{id}' has no matching relationship"));
foreach (var id in definedIds.Except(referencedIds))
result.Warnings.Add(Warning($"Orphaned relationship: Id='{id}' is defined but never referenced"));
}
private void ValidateComments(ZipArchive zip, ValidationResult result)
{
var commentFiles = new[] { "word/comments.xml", "word/commentsExtended.xml", "word/commentsIds.xml", "word/commentsExtensible.xml" };
var existing = commentFiles.Where(f => zip.GetEntry(f) != null).ToList();
if (existing.Count > 0 && existing.Count < 4)
{
var missing = commentFiles.Except(existing);
result.Warnings.Add(Warning($"Comments partially present. Missing: {string.Join(", ", missing)}"));
}
if (zip.GetEntry("word/comments.xml") is { } commentsEntry)
{
var commentsDoc = LoadXml(commentsEntry);
var commentIds = commentsDoc.Descendants(W + "comment")
.Select(c => (string?)c.Attribute(W + "id"))
.Where(id => id != null)
.ToHashSet();
if (zip.GetEntry("word/commentsExtended.xml") is { } extEntry)
{
var W15 = XNamespace.Get("http://schemas.microsoft.com/office/word/2012/wordml");
var extDoc = LoadXml(extEntry);
var extIds = extDoc.Descendants(W15 + "commentEx")
.Select(c => (string?)c.Attribute(W15 + "paraId"))
.Where(id => id != null)
.ToHashSet();
if (commentIds.Count > 0 && extIds.Count == 0)
result.Warnings.Add(Warning("comments.xml has entries but commentsExtended.xml has none"));
}
}
}
private static XDocument LoadXml(ZipArchiveEntry entry)
{
using var stream = entry.Open();
return XDocument.Load(stream);
}
private static ValidationError Error(string msg) => new() { Message = msg, Severity = "Error" };
private static ValidationError Warning(string msg) => new() { Message = msg, Severity = "Warning" };
}

View File

@@ -0,0 +1,148 @@
using System.IO.Compression;
using System.Xml.Linq;
namespace MiniMaxAIDocx.Core.Validation;
public class GateCheckResult
{
public bool Passed => Violations.Count == 0;
public List<string> Violations { get; set; } = new();
}
public class GateCheckValidator
{
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
public GateCheckResult Validate(string outputDocxPath, string templateDocxPath)
{
var result = new GateCheckResult();
var templateStyles = ExtractStyles(templateDocxPath);
var outputStyles = ExtractStyles(outputDocxPath);
var templateSectPr = ExtractSectionProperties(templateDocxPath);
var outputSectPr = ExtractSectionProperties(outputDocxPath);
// All template styles must exist in output
foreach (var style in templateStyles)
{
if (!outputStyles.Contains(style))
result.Violations.Add($"Missing style: '{style}' defined in template but absent from output");
}
// Page margins must match
if (templateSectPr.Margins != null && outputSectPr.Margins != null)
{
var tm = templateSectPr.Margins;
var om = outputSectPr.Margins;
if (tm.Top != om.Top || tm.Bottom != om.Bottom || tm.Left != om.Left || tm.Right != om.Right)
result.Violations.Add($"Page margins mismatch: template=({tm.Top},{tm.Bottom},{tm.Left},{tm.Right}) output=({om.Top},{om.Bottom},{om.Left},{om.Right})");
}
// Page size must match
if (templateSectPr.PageWidth != outputSectPr.PageWidth || templateSectPr.PageHeight != outputSectPr.PageHeight)
result.Violations.Add($"Page size mismatch: template=({templateSectPr.PageWidth}x{templateSectPr.PageHeight}) output=({outputSectPr.PageWidth}x{outputSectPr.PageHeight})");
// Default font must match
var templateFont = ExtractDefaultFont(templateDocxPath);
var outputFont = ExtractDefaultFont(outputDocxPath);
if (templateFont != null && outputFont != null && templateFont != outputFont)
result.Violations.Add($"Default font mismatch: template='{templateFont}' output='{outputFont}'");
// Heading font hierarchy consistency
ValidateHeadingFontHierarchy(outputDocxPath, result);
return result;
}
private HashSet<string> ExtractStyles(string docxPath)
{
using var zip = ZipFile.OpenRead(docxPath);
var entry = zip.GetEntry("word/styles.xml");
if (entry == null) return new();
using var stream = entry.Open();
var doc = XDocument.Load(stream);
return doc.Descendants(W + "style")
.Select(s => (string?)s.Attribute(W + "styleId"))
.Where(id => id != null)
.ToHashSet()!;
}
private record SectionProps(int PageWidth, int PageHeight, MarginInfo? Margins);
private record MarginInfo(int Top, int Bottom, int Left, int Right);
private SectionProps ExtractSectionProperties(string docxPath)
{
using var zip = ZipFile.OpenRead(docxPath);
var entry = zip.GetEntry("word/document.xml")!;
using var stream = entry.Open();
var doc = XDocument.Load(stream);
var sectPr = doc.Descendants(W + "sectPr").LastOrDefault();
if (sectPr == null) return new(0, 0, null);
int.TryParse((string?)sectPr.Element(W + "pgSz")?.Attribute(W + "w"), out var pw);
int.TryParse((string?)sectPr.Element(W + "pgSz")?.Attribute(W + "h"), out var ph);
var pgMar = sectPr.Element(W + "pgMar");
MarginInfo? margins = null;
if (pgMar != null)
{
int.TryParse((string?)pgMar.Attribute(W + "top"), out var t);
int.TryParse((string?)pgMar.Attribute(W + "bottom"), out var b);
int.TryParse((string?)pgMar.Attribute(W + "left"), out var l);
int.TryParse((string?)pgMar.Attribute(W + "right"), out var r);
margins = new(t, b, l, r);
}
return new(pw, ph, margins);
}
private string? ExtractDefaultFont(string docxPath)
{
using var zip = ZipFile.OpenRead(docxPath);
var entry = zip.GetEntry("word/styles.xml");
if (entry == null) return null;
using var stream = entry.Open();
var doc = XDocument.Load(stream);
var defaultStyle = doc.Descendants(W + "style")
.FirstOrDefault(s => (string?)s.Attribute(W + "type") == "paragraph"
&& (string?)s.Attribute(W + "default") == "1");
return (string?)defaultStyle?.Descendants(W + "rFonts").FirstOrDefault()?.Attribute(W + "ascii");
}
private void ValidateHeadingFontHierarchy(string docxPath, GateCheckResult result)
{
using var zip = ZipFile.OpenRead(docxPath);
var entry = zip.GetEntry("word/styles.xml");
if (entry == null) return;
using var stream = entry.Open();
var doc = XDocument.Load(stream);
var headingSizes = new SortedDictionary<int, int>();
foreach (var style in doc.Descendants(W + "style"))
{
var id = (string?)style.Attribute(W + "styleId");
if (id == null || !id.StartsWith("Heading", StringComparison.OrdinalIgnoreCase)) continue;
var numPart = id.AsSpan(7);
if (!int.TryParse(numPart, out var level)) continue;
var sz = (string?)style.Descendants(W + "sz").FirstOrDefault()?.Attribute(W + "val");
if (sz != null && int.TryParse(sz, out var hps))
headingSizes[level] = hps;
}
int prevSize = int.MaxValue;
foreach (var (level, size) in headingSizes)
{
if (size > prevSize)
result.Violations.Add($"Heading{level} ({size / 2}pt) is larger than a higher-level heading ({prevSize / 2}pt)");
prevSize = size;
}
}
}

View File

@@ -0,0 +1,23 @@
namespace MiniMaxAIDocx.Core.Validation;
public class ValidationResult
{
public bool IsValid => Errors.Count == 0;
public List<ValidationError> Errors { get; set; } = new();
public List<ValidationError> Warnings { get; set; } = new();
public void Merge(ValidationResult other)
{
Errors.AddRange(other.Errors);
Warnings.AddRange(other.Warnings);
}
}
public class ValidationError
{
public int LineNumber { get; set; }
public int LinePosition { get; set; }
public string Element { get; set; } = "";
public string Message { get; set; } = "";
public string Severity { get; set; } = "Error";
}

View File

@@ -0,0 +1,69 @@
using System.IO.Compression;
using System.Xml;
using System.Xml.Schema;
namespace MiniMaxAIDocx.Core.Validation;
public class XsdValidator
{
public ValidationResult Validate(string docxPath, string xsdPath)
{
using var zip = ZipFile.OpenRead(docxPath);
var entry = zip.GetEntry("word/document.xml")
?? throw new InvalidOperationException("DOCX does not contain word/document.xml");
using var stream = entry.Open();
using var reader = new StreamReader(stream);
var xmlContent = reader.ReadToEnd();
return ValidateXml(xmlContent, xsdPath);
}
public ValidationResult ValidateXml(string xmlContent, string xsdPath)
{
var result = new ValidationResult();
var settings = new XmlReaderSettings();
var schemaSet = new XmlSchemaSet();
schemaSet.Add(null, xsdPath);
settings.Schemas = schemaSet;
settings.ValidationType = ValidationType.Schema;
settings.ValidationFlags |= XmlSchemaValidationFlags.ReportValidationWarnings;
settings.ValidationEventHandler += (sender, e) =>
{
var error = new ValidationError
{
LineNumber = e.Exception?.LineNumber ?? 0,
LinePosition = e.Exception?.LinePosition ?? 0,
Message = e.Message,
Severity = e.Severity == XmlSeverityType.Warning ? "Warning" : "Error"
};
if (e.Severity == XmlSeverityType.Warning)
result.Warnings.Add(error);
else
result.Errors.Add(error);
};
using var stringReader = new StringReader(xmlContent);
using var xmlReader = XmlReader.Create(stringReader, settings);
try
{
while (xmlReader.Read()) { }
}
catch (XmlException ex)
{
result.Errors.Add(new ValidationError
{
LineNumber = ex.LineNumber,
LinePosition = ex.LinePosition,
Message = $"XML parse error: {ex.Message}",
Severity = "Error"
});
}
return result;
}
}