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:
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MiniMaxAIDocx.Core\MiniMaxAIDocx.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NeutralLanguage>en</NeutralLanguage>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.CommandLine;
|
||||
using MiniMaxAIDocx.Core.Commands;
|
||||
|
||||
var rootCommand = new RootCommand("minimax-docx: OpenXML document generation and manipulation CLI");
|
||||
|
||||
// Scenario commands
|
||||
rootCommand.Add(CreateCommand.Create());
|
||||
rootCommand.Add(EditContentCommand.Create());
|
||||
rootCommand.Add(ApplyTemplateCommand.Create());
|
||||
|
||||
// Tool commands
|
||||
rootCommand.Add(ValidateCommand.Create());
|
||||
rootCommand.Add(MergeRunsCommand.Create());
|
||||
rootCommand.Add(FixOrderCommand.Create());
|
||||
rootCommand.Add(AnalyzeCommand.Create());
|
||||
rootCommand.Add(DiffCommand.Create());
|
||||
|
||||
return rootCommand.Parse(args).Invoke();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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. <w:fldSimple w:instr="PAGE"/>
|
||||
/// - 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: <w:fldSimple w:instr=" PAGE "><w:r>...</w:r></w:fldSimple>
|
||||
///
|
||||
/// 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 }));
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
/// <w:bookmarkStart w:id="1" w:name="my_bookmark"/>
|
||||
/// ... paragraph content ...
|
||||
/// <w:bookmarkEnd w:id="1"/>
|
||||
///
|
||||
/// 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:
|
||||
/// <w:hyperlink w:anchor="bookmarkName">
|
||||
/// <w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>Click here</w:t></w:r>
|
||||
/// </w:hyperlink>
|
||||
/// </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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 >= 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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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: <w:trackChanges/> 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:
|
||||
/// <w:ins w:id="1" w:author="John" w:date="2026-03-22T00:00:00Z">
|
||||
/// <w:r>
|
||||
/// <w:t>inserted text</w:t> <!-- w:t, NOT w:delText -->
|
||||
/// </w:r>
|
||||
/// </w:ins>
|
||||
/// </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:
|
||||
/// <w:del w:id="2" w:author="John" w:date="2026-03-22T00:00:00Z">
|
||||
/// <w:r>
|
||||
/// <w:delText xml:space="preserve">deleted text</w:delText> <!-- w:delText, NOT w:t -->
|
||||
/// </w:r>
|
||||
/// </w:del>
|
||||
/// </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:
|
||||
/// <w:rPr>
|
||||
/// <w:b/> <!-- current: bold -->
|
||||
/// <w:rPrChange w:id="3" w:author="John" w:date="...">
|
||||
/// <w:rPr/> <!-- previous: no bold -->
|
||||
/// </w:rPrChange>
|
||||
/// </w:rPr>
|
||||
/// </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:
|
||||
/// <w:pPr>
|
||||
/// <w:jc w:val="center"/> <!-- current: centered -->
|
||||
/// <w:pPrChange w:id="4" w:author="John" w:date="...">
|
||||
/// <w:pPr>
|
||||
/// <w:jc w:val="left"/> <!-- previous: left -->
|
||||
/// </w:pPr>
|
||||
/// </w:pPrChange>
|
||||
/// </w:pPr>
|
||||
/// </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:
|
||||
/// <w:tr>
|
||||
/// <w:trPr>
|
||||
/// <w:ins w:id="5" w:author="John" w:date="..."/>
|
||||
/// </w:trPr>
|
||||
/// <w:tc>...</w:tc>
|
||||
/// </w:tr>
|
||||
/// </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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
4
skills/minimax-docx/scripts/dotnet/MiniMaxAIDocx.slnx
Normal file
4
skills/minimax-docx/scripts/dotnet/MiniMaxAIDocx.slnx
Normal file
@@ -0,0 +1,4 @@
|
||||
<Solution>
|
||||
<Project Path="MiniMaxAIDocx.Cli/MiniMaxAIDocx.Cli.csproj" />
|
||||
<Project Path="MiniMaxAIDocx.Core/MiniMaxAIDocx.Core.csproj" />
|
||||
</Solution>
|
||||
Reference in New Issue
Block a user