DEV Community

Cover image for I Stopped Asking Copilot to Format My 100-Page Markdown Reports. Here Is the Deterministic C# Fix
David Au Yeung
David Au Yeung

Posted on

I Stopped Asking Copilot to Format My 100-Page Markdown Reports. Here Is the Deterministic C# Fix

Introduction

Recently, I used a search AI to research a topic and it returned a result of more than 100 pages.

That sounds useful at first. But after the research is done, the next problem is very practical: how do we turn that large Markdown result into a proper Word document that people can read, review, edit, and share?

My first thought was simple:

"I will give the Markdown to Microsoft 365 Copilot and ask it to convert the content into a nicely formatted Word document."

It did not go as planned.

It converted roughly the first two pages, then confidently said the task was complete. Lol.

Even if a tool successfully converts the whole document, there is another problem: the output style may not match previous project documents. Headings look different. Tables get unexpected colors. Fonts drift. The new report looks like it came from a different project.

So I wrote a small local service instead.

The goal is intentionally boring, and that is exactly why it is useful:

Markdown file -> deterministic C# conversion -> consistently formatted .docx

No prompt. No model. No token cost. No partial response because an AI decided that two pages were enough. And most importantly, the document style is controlled in code.

This article walks through the MarkdownToWordService in a .NET 8 console project. It converts Markdown-like text files into Word documents and gives us style options for headings, body text, code blocks, lists, and tables.

What We Are Building

Search AI produces a large Markdown report
  -> save it as Markdown/Example.txt
  -> MarkdownToWordService
  -> parse headings, lists, tables, emphasis, and code blocks
  -> apply deterministic Word formatting
  -> Example.docx
Enter fullscreen mode Exit fullscreen mode

The important difference is this:

  • An AI decides how much to process and how to format it
  • A local service processes every line in the input file
  • Formatting is defined by MarkdownToWordStyleOptions
  • The same input and style options give the same output
  • The conversion costs zero AI tokens

The Demo File: Markdown/Example.txt

The project includes Markdown/Example.txt as a small input document. It contains the elements that the service understands:

  • headings
  • bold and italic text
  • inline code
  • numbered and bullet lists
  • a Markdown table
  • a horizontal rule
  • a fenced C# code block

For example, this section from Example.txt:

| Setting | Demo Value | Purpose |
|---|---|---|
| Input file | Example.txt | Markdown source document |
| Output file | Example.docx | Generated Word document |
| AI tokens | 0 | Conversion is deterministic and local |
Enter fullscreen mode Exit fullscreen mode

becomes a real Word table. It does not remain a group of pipe characters.

This makes Example.txt a useful smoke test whenever the conversion service changes.

Prerequisites

  • Visual Studio or VS Code
  • .NET 8 SDK
  • A console application
  • The Open XML SDK package

Add the package:

dotnet add package DocumentFormat.OpenXml
Enter fullscreen mode Exit fullscreen mode

The conversion is completely local. There is no Ollama server, cloud endpoint, API key, model download, or token budget required.

Step 1: Define the Conversion Options

The service accepts input, output, and document-style configuration through MarkdownToWordOptions.

public sealed class MarkdownToWordOptions
{
    public string? InputDirectory { get; init; }
    public string? FileName { get; init; }
    public string? OutputDirectory { get; init; }
    public MarkdownToWordStyleOptions Style { get; init; } = new();
}
Enter fullscreen mode Exit fullscreen mode

This is more useful than putting file paths and formatting values directly inside the converter:

  • the service can process a different report without a code change;
  • callers can choose an output directory;
  • style decisions are explicit and versionable;
  • the service still has sensible defaults for normal use.

The default input directory is the project’s Markdown folder. The console app currently uses Example.txt as the default file.

Step 2: Keep the Style in One Place

The key type is MarkdownToWordStyleOptions.

public sealed class MarkdownToWordStyleOptions
{
    public string BodyFontName { get; init; } = "Aptos";
    public int BodyFontSizeHalfPoints { get; init; } = 22;

    public string TitleColorHex { get; init; } = "156082";
    public int TitleFontSizeHalfPoints { get; init; } = 56;

    public string Heading2ColorHex { get; init; } = "404040";
    public int Heading2FontSizeHalfPoints { get; init; } = 28;

    public string Heading3ColorHex { get; init; } = "0E2841";
    public int Heading3FontSizeHalfPoints { get; init; } = 24;

    public string TableFontName { get; init; } = "Times New Roman";
    public bool ShadeTableHeader { get; init; }
    public bool BoldTableHeader { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

A small Open XML detail: Word stores font sizes in half-points.

  • 22 means 11 pt
  • 24 means 12 pt
  • 28 means 14 pt
  • 56 means 28 pt

Once the style is represented as data, it becomes easy to keep every generated report consistent.

Step 3: Use an Existing Document as the Formatting Reference

In my case, I already had an existing Word specification document with the desired appearance.

Instead of guessing at a new style, I inspected its Open XML definitions. The primary table format turned out to be much simpler than it looked:

  • Word TableGrid
  • thin borders
  • Times New Roman table text
  • no shaded header row
  • no bold header row
  • consistent paragraph spacing and cell margins

That is a useful lesson for document automation:

The best default style is often the one your readers already recognize.

A report should look as if it belongs with the rest of the project documentation.

Step 4: Convert the Markdown File

Calling the service is intentionally small:

using MyPlaygroundApp.Services.MarkdownToWord;

var service = new MarkdownToWordService();

string outputFile = await service.ConvertAsync(new MarkdownToWordOptions
{
    FileName = "Example.txt"
});

Console.WriteLine($"Word file created: {outputFile}");
Enter fullscreen mode Exit fullscreen mode

The service reads Markdown/Example.txt, creates the output directory if required, and writes Example.docx.

You can also override all paths:

string outputFile = await service.ConvertAsync(new MarkdownToWordOptions
{
    InputDirectory = @"C:\Reports\Markdown",
    FileName = "Search-AI-Research-Report.txt",
    OutputDirectory = @"C:\Reports\Word"
});
Enter fullscreen mode Exit fullscreen mode

Step 5: What the Parser Converts

This is not intended to be a full CommonMark implementation. It is a focused document converter for the Markdown patterns that appear in technical reports.

Markdown input Word output
# Title title-level paragraph
## Section second-level heading
### Detail third-level heading
**important** bold run
*emphasis* italic run
`code` monospace run
- item indented bullet paragraph
1. item indented list paragraph
--- horizontal rule
fenced code block monospace code paragraphs
pipe-delimited table Word TableGrid table

The service processes the input line by line. That may sound simple, but it is exactly what makes it predictable for large reports.

Step 6: Run It

dotnet run
Enter fullscreen mode Exit fullscreen mode

The result looks like this:

A Word About Tables

Tables were the most obvious style mismatch in the first version of the service.

A common generated-document table is visually loud:

  • blue header background;
  • bold header text;
  • custom font;
  • different border rules;
  • different spacing from the rest of the document.

That can look fine in isolation. But it does not necessarily match an existing report template.

The updated table renderer uses the reference document’s plain TableGrid approach by default. Custom behavior is still available when needed:

Style = new MarkdownToWordStyleOptions
{
    TableFontName = "Times New Roman",
    ShadeTableHeader = true,
    TableHeaderFillHex = "D9EAF7",
    BoldTableHeader = true
}
Enter fullscreen mode Exit fullscreen mode

The point is not that unshaded tables are always better. The point is that the choice belongs to your document standard, not to a model making a stylistic guess.

Why This Saves More Than Tokens

The obvious benefit is cost.

A local Open XML conversion has no per-page or per-token usage fee. That matters when a search AI produces a long research report and you need to repeat the workflow regularly.

But the more important benefits are operational:

  • Completeness - every line is processed; there is no model response cutoff.
  • Repeatability - run the same file twice and get the same document structure.
  • Consistency - style options keep reports aligned with prior documents.
  • Traceability - the source Markdown and conversion settings can be checked into version control.
  • Privacy - the document does not need to leave the machine for formatting.
  • Control - changes to style are code changes, not prompt experiments.

AI is excellent for research and drafting. Formatting a long, known document into a fixed corporate style is usually a deterministic automation problem.

A Practical Workflow for Long AI Research Results

Here is the workflow I now prefer:

  1. Use search AI to research and draft the initial material.
  2. Save or clean the result as a Markdown or text file.
  3. Put the file in the project Markdown folder.
  4. Run MarkdownToWordService.
  5. Review the generated .docx as a normal Word document.
  6. Keep the Markdown source and the final Word file together with the project.

This keeps AI where it adds the most value - discovering and drafting information - and keeps document production in a deterministic local service.

Limitations and Next Steps

The current service is deliberately lightweight. It supports the structures needed for the project examples, but it is not a complete Markdown engine.

Useful future upgrades could include:

  • nested lists and true Word numbering definitions;
  • links and bookmarks;
  • images;
  • page headers, footers, and page numbers;
  • a table of contents;
  • block quotes;
  • richer table alignment and column-width rules;
  • importing a Word template directly instead of configuring styles in code.

For many internal reports, though, headings, paragraphs, lists, tables, code blocks, and consistent fonts already solve most of the real problem.

Final Thoughts

The funny part of this experiment is that it started because an AI said it had finished converting a huge document after handling about two pages.

The useful part is the conclusion:

Use AI to help create the content. Use deterministic code to produce the document.

The result is cheaper, repeatable, and much easier to keep visually consistent with previous reports.

And when the next 100-page search result arrives, Example.txt is there as the smallest possible reminder that the workflow is just:

Markdown in -> local C# service -> consistent Word document out
Enter fullscreen mode Exit fullscreen mode

No missing 98 pages. No formatting lottery. No token bill for a task that should have been a reliable file conversion in the first place.

Complete MarkdownToWordService Source

The following is the complete current implementation from Services/MarkdownToWord/MarkdownToWordService.cs, including its options types. Readers can copy it into a .NET 8 project after adding the DocumentFormat.OpenXml package.

using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using System.Text.RegularExpressions;

namespace MyPlaygroundApp.Services.MarkdownToWord;

public sealed class MarkdownToWordOptions
{
    public string? InputDirectory { get; init; }
    public string? FileName { get; init; }
    public string? OutputDirectory { get; init; }
    public MarkdownToWordStyleOptions Style { get; init; } = new();
}

public sealed class MarkdownToWordStyleOptions
{
    public string BodyFontName { get; init; } = "Aptos";
    public int BodyFontSizeHalfPoints { get; init; } = 22;
    public string TitleColorHex { get; init; } = "156082";
    public int TitleFontSizeHalfPoints { get; init; } = 56;
    public string Heading2ColorHex { get; init; } = "404040";
    public int Heading2FontSizeHalfPoints { get; init; } = 28;
    public string Heading3ColorHex { get; init; } = "0E2841";
    public int Heading3FontSizeHalfPoints { get; init; } = 24;
    public string CodeFontName { get; init; } = "Consolas";
    public int CodeFontSizeHalfPoints { get; init; } = 20;
    public string TableFontName { get; init; } = "Times New Roman";
    public int TableLineSpacingTwips { get; init; } = 276;
    public int TableParagraphSpacingAfterTwips { get; init; } = 200;
    public int TableCellHorizontalMarginTwips { get; init; } = 108;
    public bool ShadeTableHeader { get; init; }
    public string TableHeaderFillHex { get; init; } = "FFFFFF";
    public bool BoldTableHeader { get; init; }
    public int ListIndentTwips { get; init; } = 720;
}

public sealed class MarkdownToWordService
{
    private const string DefaultFileName = "Example.txt";

    public async Task<string> ConvertAsync(MarkdownToWordOptions? options = null, CancellationToken cancellationToken = default)
    {
        string projectRoot = ResolveProjectRoot();
        string inputDirectory = string.IsNullOrWhiteSpace(options?.InputDirectory)
            ? Path.Combine(projectRoot, "Markdown")
            : options!.InputDirectory!;

        string fileName = string.IsNullOrWhiteSpace(options?.FileName)
            ? DefaultFileName
            : options!.FileName!;

        string outputDirectory = string.IsNullOrWhiteSpace(options?.OutputDirectory)
            //? @"C:\Downloads"
            ? Path.Combine(projectRoot, "Markdown")
            : options!.OutputDirectory!;

        string inputFilePath = Path.Combine(inputDirectory, fileName);
        if (!File.Exists(inputFilePath))
        {
            throw new FileNotFoundException($"Markdown file was not found: {inputFilePath}", inputFilePath);
        }

        Directory.CreateDirectory(outputDirectory);

        string markdown = await File.ReadAllTextAsync(inputFilePath, cancellationToken);
        string outputFilePath = Path.Combine(outputDirectory, $"{Path.GetFileNameWithoutExtension(fileName)}.docx");

        CreateWordDocument(markdown, outputFilePath, options?.Style ?? new MarkdownToWordStyleOptions());

        return outputFilePath;
    }

    private static void CreateWordDocument(string markdown, string outputPath, MarkdownToWordStyleOptions style)
    {
        using WordprocessingDocument wordDocument = WordprocessingDocument.Create(outputPath, WordprocessingDocumentType.Document);

        MainDocumentPart mainPart = wordDocument.AddMainDocumentPart();
        mainPart.Document = new Document();
        Body body = new();

        var lines = markdown.Replace("\r\n", "\n").Split('\n');
        bool inFence = false;

        for (int i = 0; i < lines.Length; i++)
        {
            string rawLine = lines[i].TrimEnd();
            string line = rawLine.TrimStart();

            if (line.StartsWith("```
{% endraw %}
", StringComparison.Ordinal))
            {
                inFence = !inFence;
                body.Append(CreateCodeParagraph(inFence ? "[CODE BLOCK START]" : "[CODE BLOCK END]", style));
                continue;
            }

            if (inFence)
            {
                body.Append(CreateCodeParagraph(rawLine, style));
                continue;
            }

            if (string.IsNullOrWhiteSpace(line))
            {
                body.Append(new Paragraph());
                continue;
            }

            if (IsHorizontalRule(line))
            {
                body.Append(CreateHorizontalRuleParagraph());
                continue;
            }

            if (TryParseTable(lines, ref i, body, style))
            {
                continue;
            }

            if (IsHeading(line, out string headingText, out int headingLevel))
            {
                body.Append(CreateHeadingParagraph(headingText, headingLevel, style));
                continue;
            }

            if (TryParseListItem(line, out string listText, out bool isNumbered))
            {
                body.Append(CreateListParagraph(listText, isNumbered, style));
                continue;
            }

            body.Append(CreateParagraphWithInlineFormatting(line, style));
        }

        mainPart.Document.Append(body);
        mainPart.Document.Save();
    }

    private static bool IsHeading(string line, out string headingText, out int headingLevel)
    {
        headingText = string.Empty;
        headingLevel = 0;

        var match = Regex.Match(line, @"^(#{1,6})\s+(.*)$");
        if (!match.Success)
        {
            return false;
        }

        headingLevel = match.Groups[1].Value.Length;
        headingText = match.Groups[2].Value.Trim();
        return true;
    }

    private static bool IsHorizontalRule(string line)
    {
        string compact = line.Replace(" ", string.Empty).Replace("\t", string.Empty);
        return compact.Length >= 3
               && (compact.All(c => c == '-')
                   || compact.All(c => c == '_')
                   || compact.All(c => c == '*'));
    }

    private static bool TryParseListItem(string line, out string text, out bool isNumbered)
    {
        text = string.Empty;
        isNumbered = false;

        var bullet = Regex.Match(line, @"^[-*+]\s+(.*)$");
        if (bullet.Success)
        {
            text = bullet.Groups[1].Value.Trim();
            return true;
        }

        var numbered = Regex.Match(line, @"^\d+\.\s+(.*)$");
        if (numbered.Success)
        {
            isNumbered = true;
            text = numbered.Groups[1].Value.Trim();
            return true;
        }

        return false;
    }

    private static bool TryParseTable(string[] lines, ref int currentIndex, Body body, MarkdownToWordStyleOptions style)
    {
        if (currentIndex + 1 >= lines.Length)
        {
            return false;
        }

        string headerLine = lines[currentIndex].Trim();
        string separatorLine = lines[currentIndex + 1].Trim();

        if (!headerLine.StartsWith('|') || !headerLine.EndsWith('|'))
        {
            return false;
        }

        if (!Regex.IsMatch(separatorLine, @"^\|\s*:?-+\s*(\|\s*:?-+\s*)+\|$"))
        {
            return false;
        }

        var tableRows = new List<List<string>>
        {
            ParseTableRow(headerLine)
        };

        int rowIndex = currentIndex + 2;
        while (rowIndex < lines.Length)
        {
            string candidate = lines[rowIndex].Trim();
            if (!candidate.StartsWith('|') || !candidate.EndsWith('|'))
            {
                break;
            }

            tableRows.Add(ParseTableRow(candidate));
            rowIndex++;
        }

        body.Append(CreateTable(tableRows, style));
        currentIndex = rowIndex - 1;
        return true;
    }

    private static List<string> ParseTableRow(string line)
    {
        return line.Trim()
            .Trim('|')
            .Split('|')
            .Select(cell => cell.Trim())
            .ToList();
    }

    private static Table CreateTable(List<List<string>> rows, MarkdownToWordStyleOptions style)
    {
        var table = new Table();
        table.Append(new TableProperties(
            new TableStyle { Val = "TableGrid" },
            new TableWidth { Width = "0", Type = TableWidthUnitValues.Auto },
            new TableLook { Val = "04A0", FirstRow = true, FirstColumn = true, NoHorizontalBand = false, NoVerticalBand = true },
            new TableBorders(
                new TopBorder { Val = BorderValues.Single, Size = 4 },
                new BottomBorder { Val = BorderValues.Single, Size = 4 },
                new LeftBorder { Val = BorderValues.Single, Size = 4 },
                new RightBorder { Val = BorderValues.Single, Size = 4 },
                new InsideHorizontalBorder { Val = BorderValues.Single, Size = 4 },
                new InsideVerticalBorder { Val = BorderValues.Single, Size = 4 }),
            new TableCellMarginDefault(
                new LeftMargin { Width = style.TableCellHorizontalMarginTwips.ToString(), Type = TableWidthUnitValues.Dxa },
                new RightMargin { Width = style.TableCellHorizontalMarginTwips.ToString(), Type = TableWidthUnitValues.Dxa })));

        for (int r = 0; r < rows.Count; r++)
        {
            var row = new TableRow();
            foreach (string cell in rows[r])
            {
                var cellParagraph = CreateTableParagraph(style);
                AddInlineRuns(
                    cellParagraph,
                    cell,
                    style,
                    r == 0 && style.BoldTableHeader ? new RunProperties(new Bold()) : null,
                    style.TableFontName);

                row.Append(new TableCell(
                    new TableCellProperties(
                        new Shading
                        {
                            Val = ShadingPatternValues.Clear,
                            Fill = r == 0 && style.ShadeTableHeader ? style.TableHeaderFillHex : "FFFFFF"
                        }),
                    cellParagraph));
            }

            table.Append(row);
        }

        return table;
    }

    private static Paragraph CreateTableParagraph(MarkdownToWordStyleOptions style)
    {
        return new Paragraph(new ParagraphProperties(
            new SpacingBetweenLines
            {
                After = style.TableParagraphSpacingAfterTwips.ToString(),
                Line = style.TableLineSpacingTwips.ToString(),
                LineRule = LineSpacingRuleValues.Auto
            },
            new RunProperties(
                new RunFonts { Ascii = style.TableFontName, HighAnsi = style.TableFontName },
                new FontSize { Val = style.BodyFontSizeHalfPoints.ToString() })));
    }

    private static Paragraph CreateHeadingParagraph(string text, int headingLevel, MarkdownToWordStyleOptions style)
    {
        var paragraph = new Paragraph();
        int fontSize = headingLevel == 1 ? style.TitleFontSizeHalfPoints : headingLevel == 2 ? style.Heading2FontSizeHalfPoints : style.Heading3FontSizeHalfPoints;
        string color = headingLevel == 1 ? style.TitleColorHex : headingLevel == 2 ? style.Heading2ColorHex : style.Heading3ColorHex;
        paragraph.ParagraphProperties = new ParagraphProperties(new SpacingBetweenLines { Before = headingLevel == 1 ? "0" : headingLevel == 2 ? "80" : "40", After = "0", Line = "240", LineRule = LineSpacingRuleValues.Auto });
        AddInlineRuns(paragraph, text, style, new RunProperties(new FontSize { Val = fontSize.ToString() }, new Color { Val = color }, new Bold()));
        return paragraph;
    }

    private static Paragraph CreateListParagraph(string text, bool isNumbered, MarkdownToWordStyleOptions style)
    {
        var paragraph = CreateBodyParagraph(style);
        paragraph.ParagraphProperties!.Append(new Indentation { Left = style.ListIndentTwips.ToString() });
        string prefix = isNumbered ? "• " : "• ";
        paragraph.Append(new Run(new Text(prefix) { Space = SpaceProcessingModeValues.Preserve }));
        AddInlineRuns(paragraph, text, style);
        return paragraph;
    }

    private static Paragraph CreateCodeParagraph(string text, MarkdownToWordStyleOptions style)
    {
        var paragraph = new Paragraph();
        paragraph.Append(new Run(
            new RunProperties(
                new FontSize { Val = style.CodeFontSizeHalfPoints.ToString() },
                new RunFonts { Ascii = style.CodeFontName, HighAnsi = style.CodeFontName }),
            new Text(text) { Space = SpaceProcessingModeValues.Preserve }));
        return paragraph;
    }

    private static Paragraph CreateHorizontalRuleParagraph()
    {
        var paragraph = new Paragraph();
        paragraph.ParagraphProperties = new ParagraphProperties(
            new ParagraphBorders(
                new BottomBorder
                {
                    Val = BorderValues.Single,
                    Size = 8,
                    Space = 1
                }));
        paragraph.Append(new Run(new Text(string.Empty)));
        return paragraph;
    }

    private static Paragraph CreateParagraphWithInlineFormatting(string text, MarkdownToWordStyleOptions style)
    {
        var paragraph = CreateBodyParagraph(style);
        AddInlineRuns(paragraph, text, style);
        return paragraph;
    }

    private static Paragraph CreateBodyParagraph(MarkdownToWordStyleOptions style)
    {
        return new Paragraph(new ParagraphProperties(new SpacingBetweenLines { After = "0", Line = "240", LineRule = LineSpacingRuleValues.Auto }, new RunProperties(new RunFonts { Ascii = style.BodyFontName, HighAnsi = style.BodyFontName }, new FontSize { Val = style.BodyFontSizeHalfPoints.ToString() })));
    }

    private static void AddInlineRuns(Paragraph paragraph, string text, MarkdownToWordStyleOptions style, RunProperties? baseProperties = null, string? fontName = null)
    {
        string selectedFontName = fontName ?? style.BodyFontName;

        RunProperties GetRunProperties(params OpenXmlElement[] formatting)
        {
            var properties = new RunProperties(
                new RunFonts { Ascii = selectedFontName, HighAnsi = selectedFontName },
                new FontSize { Val = style.BodyFontSizeHalfPoints.ToString() });

            if (baseProperties is not null)
            {
                foreach (OpenXmlElement property in baseProperties.ChildElements)
                {
                    properties.Append(property.CloneNode(true));
                }
            }

            foreach (OpenXmlElement property in formatting)
            {
                properties.Append(property);
            }

            return properties;
        }

        int index = 0;
        while (index < text.Length)
        {
            if (TryMatchPrefix(text, index, "**", out int boldEnd))
            {
                string value = text[(index + 2)..boldEnd];
                paragraph.Append(new Run(
                    GetRunProperties(new Bold()),
                    new Text(value) { Space = SpaceProcessingModeValues.Preserve }));
                index = boldEnd + 2;
                continue;
            }

            if (TryMatchPrefix(text, index, "__", out boldEnd))
            {
                string value = text[(index + 2)..boldEnd];
                paragraph.Append(new Run(
                    GetRunProperties(new Bold(), new Underline { Val = UnderlineValues.Single }),
                    new Text(value) { Space = SpaceProcessingModeValues.Preserve }));
                index = boldEnd + 2;
                continue;
            }

            if (TryMatchPrefix(text, index, "*", out int italicEnd))
            {
                string value = text[(index + 1)..italicEnd];
                paragraph.Append(new Run(
                    GetRunProperties(new Italic()),
                    new Text(value) { Space = SpaceProcessingModeValues.Preserve }));
                index = italicEnd + 1;
                continue;
            }

            if (TryMatchPrefix(text, index, "{% raw %}`", out int codeEnd))
            {
                string value = text[(index + 1)..codeEnd];
                paragraph.Append(new Run(
                    GetRunProperties(new RunFonts { Ascii = style.CodeFontName, HighAnsi = style.CodeFontName }),
                    new Text(value) { Space = SpaceProcessingModeValues.Preserve }));
                index = codeEnd + 1;
                continue;
            }

            int nextToken = FindNextTokenIndex(text, index);
            string plainText = text[index..nextToken];
            paragraph.Append(new Run(GetRunProperties(), new Text(plainText) { Space = SpaceProcessingModeValues.Preserve }));
            index = nextToken;
        }
    }

    private static bool TryMatchPrefix(string text, int startIndex, string token, out int endIndex)
    {
        endIndex = -1;

        if (startIndex < 0 || startIndex >= text.Length)
        {
            return false;
        }

        if (!text.AsSpan(startIndex).StartsWith(token))
        {
            return false;
        }

        int searchIndex = startIndex + token.Length;
        if (searchIndex > text.Length)
        {
            return false;
        }

        endIndex = text.IndexOf(token, searchIndex, StringComparison.Ordinal);
        return endIndex > startIndex;
    }

    private static int FindNextTokenIndex(string text, int startIndex)
    {
        int bold = text.IndexOf("**", startIndex, StringComparison.Ordinal);
        int underline = text.IndexOf("__", startIndex, StringComparison.Ordinal);
        int italic = text.IndexOf('*', startIndex);
        int code = text.IndexOf('`{% endraw %}', startIndex);

        int next = text.Length;
        if (bold >= 0) next = Math.Min(next, bold);
        if (underline >= 0) next = Math.Min(next, underline);
        if (italic >= 0) next = Math.Min(next, italic);
        if (code >= 0) next = Math.Min(next, code);

        return next;
    }

    private static string ResolveProjectRoot()
    {
        DirectoryInfo? dir = new(AppContext.BaseDirectory);

        while (dir is not null)
        {
            if (dir.EnumerateFiles("*.csproj", SearchOption.TopDirectoryOnly).Any())
            {
                return dir.FullName;
            }

            dir = dir.Parent;
        }

        return Directory.GetCurrentDirectory();
    }
}

{% raw %}
Enter fullscreen mode Exit fullscreen mode

Love C# & AI!

Top comments (0)