{
  "markdown": "# Kookerella.FsWordDsl\n\nA typesafe F# DSL for building Word documents, interpreted into calls against the\n[DocumentFormat.OpenXml](https://github.com/dotnet/Open-XML-SDK) SDK. The DSL is a plain\ndata model (records/DUs with structural equality) - the interpreter (`Writer`) compiles it\nto OOXML, and the reverse transform (`Reader`) parses an existing `.docx` back into the\nsame DSL.\n\nThis is the WordprocessingML sibling of\n[Kookerella.FsOpenXmlDsl](https://github.com/Kookerella-Ltd/Kookerella.FsOpenXmlDsl) (the\nExcel/SpreadsheetML one) - same objectives, same round-trip philosophy, translated to\nWord's own document model. See [MAPPING.md](MAPPING.md) for exactly which WordprocessingML\nfeatures map 1:1, which are approximated, and which aren't modeled yet.\n\n**This round-trips in both directions**, which most Word libraries don't: they give you an\nimperative API to build a document from scratch, but no way to turn an *existing* file back\ninto readable source. Here, `Reader` parses a real `.docx`/`.docm` back into the same DSL,\nand `Document.generateScript` goes one step further and renders that model back out as a\nself-contained script that rebuilds an equivalent file - a decompiler for Word documents,\nnot just a writer. Two more surfaces, `Xml.toDocument`/`Xml.ofDocument` (see [\"## XML\"](#xml)\nbelow) and `Json.toDocument`/`Json.ofDocument` (see [\"## JSON\"](#json) below), do the same\ntranslation to/from plain XML or JSON against a real schema - for a caller who'd rather\ngenerate or consume data than write code at all.\n\n**A fluent C# wrapper** (`Kookerella.CsWordDsl`) sits on top of the F# core for callers who'd\nrather not touch F# discriminated unions/option types directly - immutable records with\n`With*` builders, plus its own `CsCodeGen` decompiler that renders a `Document` back out as\nrunnable C# source. See [\"## The C# wrapper\"](#the-c-wrapper) below.\n\n**An MCP server** (`Kookerella.FsWordDsl.Mcp`) exposes the same read/write/decompile\ncapabilities as tools any MCP-compatible AI agent can call directly, and doubles as a plain\nCLI (`fsworddsl-mcp convert`/`build`) for anyone not going through an MCP client at all -\nsee [its own README](src/Kookerella.FsWordDsl.Mcp/README.md) for the full tool list.\n\n## Layout\n\n- `src/Kookerella.FsWordDsl` - the library.\n  - `Units.fs` - conversions between points/inches/pixels and the physical units\n    WordprocessingML uses on the wire (twips for page geometry/spacing, EMU for image\n    sizing).\n  - `Styles.fs` - character and paragraph formatting: `Color` (`Rgb`, `Auto`, or a\n    theme-relative `Theme` color - see `ThemeColorKind`), `HighlightColor` (Word's own\n    fixed highlight palette), `UnderlineStyle`, `RunStyle` (including small caps/all\n    caps/hidden text), `ParagraphAlignment`, `Indentation`, `LineSpacingRule`,\n    `TabStopAlignment`/`TabLeader`/`TabStop`, `ParagraphFormat` (including paragraph\n    borders, shading, and custom tab stops), `BorderLineStyle`, `BorderSide`,\n    `BorderStyle` (reused for both paragraph and table borders).\n  - `NamedStyles.fs` - `StyleDefinition` (paragraph or character, with `BasedOn`\n    inheritance) and a small `BuiltInStyles` catalog (`normal`, `heading1`/`2`/`3`,\n    `title`, `listParagraph`, `hyperlinkCharStyle`).\n  - `Numbering.fs` - `NumberFormatKind`, `ListLevel`, `NumberingDefinition` for\n    numbered/bulleted lists, including multi-level ones (`ListLevel` isn't limited to one\n    per definition - see `Builders.multiLevelNumberedListDef`).\n  - `Hyperlinks.fs` - `HyperlinkTarget` (external URL vs. internal bookmark reference).\n  - `Protection.fs` - `EditRestriction` and `DocumentProtection`, document-level (Word has\n    no per-section equivalent of Excel's per-sheet protection).\n  - `Revisions.fs` - `RevisionKind`/`Revision` for track changes (`Inline.TrackedChange`,\n    `Paragraph.MarkRevision`) - narrowly scoped to inserted/deleted content and paragraph\n    marks, see `MAPPING.md` for what isn't covered.\n  - `ContentControls.fs` - `ContentControlType`/`ContentControlProps` for content controls\n    (structured document tags, `w:sdt`): plain text, rich text, dropdown/combo box, date\n    picker, checkbox - see `MAPPING.md` for what isn't covered.\n  - `PageSetup.fs` - `PageOrientation`, `PageSize`, `PageMargins`, `SectionBreakType`,\n    `NoteNumberRestart`/`NoteNumberingSettings` (a section's own footnote/endnote\n    numbering).\n  - `Tables.fs` - `TableBorders`, `VerticalMergeKind`, `TableCellProps` (including a\n    per-cell `Margins` override), `TableStyleRef`, `TableStyleRegion`/\n    `TableStyleDefinition` (custom table style definitions - all thirteen of OOXML's\n    conditional-formatting regions), and `CellMargins` (shared shape for a table's default\n    margins and a single cell's own override).\n  - `Images.fs` - `ImageFormat`, `ImageEntry` (raw file bytes plus an on-page size),\n    anchored inline within a run.\n  - `DocumentProperties.fs` - `DocumentProperties` (Title, Author, Subject, Keywords,\n    Comments, Category, Company) - core document metadata, `Document.Properties`.\n  - `Model.fs` - the recursive content model: `Inline` (runs, breaks, images, hyperlinks,\n    bookmarks and comments - both the single-paragraph `Bookmark`/`Comment` cases and the\n    cross-paragraph `BookmarkRangeStart`/`End`/`CommentRangeStart`/`End` markers, simple\n    fields, footnotes/endnotes, `TrackedChange` for track changes, and\n    `InlineContentControl` for content controls), `Paragraph` (including `MarkRevision`),\n    `Block` (paragraph, table, or `ContentControlBlock` - the block-level counterpart to\n    `InlineContentControl`), `TableCell`/\n    `TableRow` (including `RepeatAsHeader`)/`TableEntry` (including `CellMargins`),\n    `HeaderFooterSet`,\n    `SectionProperties` (including `BreakType` and `FootnoteNumbering`/\n    `EndnoteNumbering`), `Section`, `Document` (including `Document.VbaProject`, a\n    macro-enabled document's raw `vbaProject.bin` bytes, `Document.Properties`, and\n    `Document.TableStyles`).\n  - `Xml.fs` / `Xml.xsd` - the XML surface: `Xml.toDocument`/`Xml.ofDocument` translate a\n    `Document` to/from an `XElement` tree, and `Xml.schemaSet()` loads the paired schema\n    (embedded in the assembly as a resource) for validating either direction. See\n    [\"## XML\"](#xml) below.\n  - `Json.fs` / `Json.schema.json` - the JSON surface: `Json.toDocument`/`Json.ofDocument`\n    translate a `Document` to/from a `System.Text.Json.Nodes.JsonObject` tree. Schema\n    validation is test-suite only, not a public API. See [\"## JSON\"](#json) below.\n  - `Builders.fs` - plain functional constructors (`section`, `document`, `withStyles`,\n    `withNumbering`, `withProtection`, `withVbaProject`, `withDocumentProperties`,\n    `withTableStyles`, `bulletListDef`, `numberedListDef`, `multiLevelNumberedListDef`)\n    plus `DocumentDsl` - smart constructors (`run`, `para` (with `markRevision`),\n    `hyperlink`, `bookmark`, `comment`, `inserted`/`deleted` (track changes),\n    `contentControl`/`contentControlBlock` (content controls), `image`, `footnote`,\n    `endnote`, `tableCell`, `tableRow` (with `height`/`repeatAsHeader`), `table` (with\n    `style`/`borders`/`cellMargins`)) with real optional parameters, the Word analog of\n    the Excel repo's `SheetDsl`.\n  - `Interpreter/StyleRegistry.fs` - shared run/paragraph/border/color conversions plus\n    `Document.Styles` <-> `styles.xml` (internal).\n  - `Interpreter/ImageWriter.fs` / `ImageReader.fs` - an inline image's own DSL <->\n    DrawingML translation (internal).\n  - `Interpreter/Writer.fs` - DSL -> OOXML (internal).\n  - `Interpreter/Reader.fs` - OOXML -> DSL, the reverse transform (internal).\n  - `Interpreter/CodeGen.fs` - DSL -> F# *source text*: renders a `Document` back out as a\n    self-contained `.fsx` script that rebuilds an equivalent file when run (internal).\n  - `Api.fs` - the public `Document.save`/`saveToStream`/`load`/`loadFromStream`/\n    `generateScript` entry points.\n- `src/Kookerella.CsWordDsl` - the fluent C# wrapper (see [\"## The C# wrapper\"](#the-c-wrapper)\n  below): immutable records/`sealed record` closed hierarchies mirroring the F# core's own\n  types one-for-one, `DocumentConverter.cs` (internal, the two-way F#<->C# translation),\n  `DocumentIO.cs` (`Save`/`Load`, the one place this project does I/O), `CsCodeGen.cs` (DSL\n  -> C# *source text*, the C# analog of `Interpreter/CodeGen.fs`).\n- `src/Kookerella.FsWordDsl.Mcp` - the MCP server (see [its own README](src/Kookerella.FsWordDsl.Mcp/README.md)):\n  `DocumentTools.fs` (the tool surface, one `[<McpServerTool>]`-tagged member per tool),\n  `Program.fs` (dispatches to the MCP stdio server, or to a plain `convert`/`build` CLI,\n  depending on `argv`). Distributed as a `dotnet tool` (`fsworddsl-mcp`), same as the Excel\n  sibling's own `Kookerella.FsOpenXmlDsl.Mcp`.\n- `tests/Kookerella.FsWordDsl.Tests` - one test per feature, each validating the produced\n  file against the OOXML schema (`DocumentFormat.OpenXml.Validation.OpenXmlValidator`) and\n  asserting an exact round trip back through the DSL. Each test also writes the document it\n  builds to `Examples/<test name>/output.docx` (checked into the repo), plus `script.fsx`\n  (regenerates the file - a separate, slower `Category=Slow` test group actually executes\n  each one via `dotnet fsi`), `document.xml`, and `document.json` - one folder always has\n  four views of the same example.\n- `tests/Kookerella.CsWordDsl.Tests` - `DriftGuardTests.cs` (a reflection-based tripwire\n  checking the C# wrapper's DU mirrors haven't fallen behind the F# core's own case\n  counts), `DocumentTests.cs` (targeted round-trip assertions per feature),\n  `ExampleTests.cs` (reloads the F# suite's own checked-in `Examples/*/output.docx`\n  fixtures rather than re-authoring every scenario a second time), `CsCodeGenTests.cs`\n  (actually executes a generated file via `dotnet run --file`, the C# analog of the F#\n  suite's `Category=Slow` `dotnet fsi` group).\n- `samples/Kookerella.FsWordDsl.Sample` - a small console app that builds a document, saves\n  it, and reads it back.\n\n## Quick start\n\n```fsharp\nopen Kookerella.FsWordDsl\nopen type Kookerella.FsWordDsl.DocumentDsl\n\nlet doc =\n    document\n        [ section\n              [ para ([ run \"Quarterly Report\" ], styleId = \"Title\")\n                para\n                    [ run \"This report covers \"\n                      run (\"Q1 2026\", style = { RunStyle.Default with Bold = true })\n                      run \", see the \"\n                      hyperlink (\"full dataset\", ExternalUrl \"https://example.com/data\")\n                      run \" for details.\" ] ] ]\n\ndoc |> Document.save \"report.docx\"\n\n// Reverse transform:\nlet roundTripped = Document.load \"report.docx\"\n```\n\n`document` defaults `Styles` to `BuiltInStyles.all`, so `styleId = \"Heading1\"` (or any other\nbuilt-in id) just works without registering it first - pipe `withStyles` afterward to\nreplace or extend that set. `run`/`para`/`hyperlink`/`bookmark`/`comment`/`image`/\n`tableCell`/`tableRow`/`table` are `DocumentDsl` members with real optional parameters\n(`open type Kookerella.FsWordDsl.DocumentDsl` brings them into scope unqualified, same as\n`open type SheetDsl` does in the Excel repo) - plain F# `let` bindings can't have optional\nparameters, which is why this part of the DSL is a type.\n\nA `Paragraph`'s `Inlines` are naturally several independently-styled runs - rich text\n(mixed formatting within one paragraph) is first-class, not a documented gap the way\nExcel's single-uniform-run `Text` cell is:\n\n```fsharp\npara\n    [ run \"Plain text, \"\n      run (\"bold\", style = { RunStyle.Default with Bold = true })\n      run \", and \"\n      run (\"colored\", style = { RunStyle.Default with Color = Some Color.red }) ]\n```\n\n`RunStyle` also covers small caps, all caps, and hidden text; `ParagraphFormat` covers\nborders (`BorderStyle`, the same shape used for table borders) and shading:\n\n```fsharp\npara\n    ([ run \"ALL CAPS AND SMALL CAPS\" ], format =\n        { ParagraphFormat.Default with\n            Borders = Some { BorderStyle.None with Bottom = Some { Style = SingleLine; Width = Some 1.0; Color = Some Color.black } }\n            Shading = Some(Rgb(0xD9uy, 0xD9uy, 0xD9uy)) })\n```\n\nCustom tab stops (`TabStop`) sit on `ParagraphFormat.TabStops` - a right-aligned stop with a\ndot leader is the classic table-of-contents pattern:\n\n```fsharp\npara\n    ([ run \"Introduction\"; Tab; run \"1\" ], format =\n        { ParagraphFormat.Default with TabStops = [ { Position = 288.0; Alignment = RightTab; Leader = DotLeader } ] })\n```\n\n`Color` also accepts a theme-relative token (`Theme`) alongside plain `Rgb`/`Auto` - since\nthis DSL has no theme part to resolve it against, real Word does that; `Fallback` is what a\nthemeless reader sees instead, the same \"always also write a computed value\" convention Word\nitself follows:\n\n```fsharp\nrun (\"Accent-colored text\", style = { RunStyle.Default with Color = Some(Theme(Accent1Theme, (0x1Fuy, 0x49uy, 0x7Duy), None, None)) })\n```\n\nLists use a `(numId, level)` reference on the paragraph, resolved against a\n`NumberingDefinition` attached to the document - `NumberingDefinition.Levels` isn't limited\nto one level, and `multiLevelNumberedListDef` builds the common correctly-linked outline\nshape for you:\n\n```fsharp\ndocument\n    [ section\n          [ para ([ run \"First bullet\" ], numbering = (1, 0))\n            para ([ run \"Second bullet\" ], numbering = (1, 0)) ] ]\n|> withNumbering [ bulletListDef 1 ]\n\ndocument\n    [ section\n          [ para ([ run \"First topic\" ], numbering = (1, 0))\n            para ([ run \"First subtopic\" ], numbering = (1, 1))\n            para ([ run \"Second topic\" ], numbering = (1, 0)) ] ]\n|> withNumbering [ multiLevelNumberedListDef 1 3 ]\n```\n\nTables are built from `tableRow`/`tableCell`, with column widths given once for the whole\ntable - a cell without an explicit width falls back to its column's width at write time:\n\n```fsharp\ntable (\n    [ tableRow [ tableCell [ para [ run \"Item\" ] ]; tableCell [ para [ run \"Qty\" ] ] ]\n      tableRow [ tableCell [ para [ run \"Widgets\" ] ]; tableCell [ para [ run \"12\" ] ] ] ],\n    [ 200.0; 100.0 ],\n    style = TableStyleRef.Default\n)\n```\n\nCell merging - horizontal (`GridSpan`) and vertical (`RestartMerge`/`ContinueMerge`) - are\nindependent and combine on the same cell, matching real Word:\n\n```fsharp\ntableCell ([ para [ run \"Spans 2 columns\" ] ], props = { TableCellProps.Default with GridSpan = Some 2 })\n```\n\nA cell's own margins override the table's default the same `CellMargins` shape covers both:\n\n```fsharp\ntableCell ([ para [ run \"Extra padding\" ] ], props = { TableCellProps.Default with Margins = Some { CellMargins.Default with Top = Some 8.0; Bottom = Some 8.0 } })\n```\n\nA custom table style (`TableStyleDefinition`) lives in `Document.TableStyles` and is applied\nby name, the same way a built-in like `\"TableGrid\"` is - here with a bold white header row on\na blue background, an italic last row, and alternating row shading, plus a table-wide default\ncell margin and a row that repeats on every page:\n\n```fsharp\nlet corporateStyle: TableStyleDefinition =\n    { TableStyleDefinition.Default with\n        Id = \"Corporate\"\n        Name = \"Corporate\"\n        FirstRow =\n            { TableStyleRegion.None with\n                RunFormat = Some { RunStyle.Default with Bold = true; Color = Some Color.white }\n                CellShading = Some(Rgb(0x4Fuy, 0x81uy, 0xBDuy)) }\n        LastRow = { TableStyleRegion.None with RunFormat = Some { RunStyle.Default with Italic = true } }\n        BandedRow = { TableStyleRegion.None with CellShading = Some(Rgb(0xDCuy, 0xE6uy, 0xF1uy)) } }\n\ndocument\n    [ section\n          [ table (\n                [ tableRow ([ tableCell [ para [ run \"Item\" ] ]; tableCell [ para [ run \"Qty\" ] ] ], repeatAsHeader = true)\n                  tableRow [ tableCell [ para [ run \"Widgets\" ] ]; tableCell [ para [ run \"12\" ] ] ] ],\n                [ 200.0; 100.0 ],\n                style = { TableStyleRef.Default with Name = \"Corporate\" },\n                cellMargins = { Top = Some 4.0; Bottom = Some 4.0; Left = Some 6.0; Right = Some 6.0 }\n            ) ] ]\n|> withTableStyles [ corporateStyle ]\n```\n\n`TableStyleDefinition` also covers `FirstColumn`/`LastColumn`, `BandedColumn`, and the four\ncorner cells (`NorthEastCell`/`NorthWestCell`/`SouthEastCell`/`SouthWestCell`) - the two\nregions not modeled are each banding axis's *second* band, since in practice that's just\n`WholeTable`'s own background showing through (see [MAPPING.md](MAPPING.md)).\n\nSections carry their own page setup - a document is a sequence of `Section`s, mapping 1:1\nonto real Word section breaks. `BreakType` is how a section begins *relative to the\nprevious one* - meaningless (and not written) on the very first section:\n\n```fsharp\nlet landscape = { SectionProperties.Default with Orientation = Landscape }\ndocument [ sectionWith landscape [ para [ run \"A landscape-oriented page.\" ] ] ]\n\nlet continuous = { SectionProperties.Default with BreakType = ContinuousBreak }\ndocument\n    [ section [ para [ run \"Section 1.\" ] ]\n      sectionWith continuous [ para [ run \"Section 2 - no page break from section 1.\" ] ] ]\n```\n\nFootnotes and endnotes mark a point in a paragraph's own `Inlines` - `content` is the\nnote's own body, written to `word/footnotes.xml`/`endnotes.xml` with an id `Writer` assigns\nautomatically (the reference-mark run itself is generated for you, on both ends):\n\n```fsharp\npara\n    [ run \"This claim needs a citation\"\n      footnote \"Smith, J. (2023). A Study of Claims.\"\n      run \", and this one refers to a fuller discussion\"\n      endnote [ para [ run \"See the appendix for the full derivation.\" ] ] ]\n```\n\nA section's own footnote/endnote numbering (`w:footnotePr`/`w:endnotePr`) - `None` is Word's\nown default (continuous decimal from 1); here footnotes are lower-roman and restart every\npage, matching a common legal-document convention:\n\n```fsharp\nsectionWith\n    { SectionProperties.Default with FootnoteNumbering = Some { Format = LowerRomanFormat; StartAt = None; Restart = RestartEachPage } }\n    [ para [ run \"Body text.\"; footnote \"A footnote numbered i, ii, iii, ... restarting each page.\" ] ]\n```\n\nHeaders and footers are per-section, with `Default`/`First`/`Even` variants (the\n`titlePg`/`evenAndOddHeaders` flags real Word needs are set automatically):\n\n```fsharp\nlet footer = { HeaderFooterSet.None with Default = Some [ para [ run \"Page \"; Field(\"PAGE\", Some \"1\") ] ] }\nsectionWith { SectionProperties.Default with Footer = Some footer } [ para [ run \"Body text.\" ] ]\n```\n\nComments and bookmarks wrap inline content directly, the common single-paragraph case:\n\n```fsharp\npara [ comment ([ run \"This figure needs review.\" ], \"Please double check the totals.\", author = \"Alex\") ]\n```\n\nEither spanning more than one paragraph uses two independent markers placed directly in\nseparate paragraphs instead, sharing an id - `BookmarkRangeStart`/`BookmarkRangeEnd` for\nbookmarks, `CommentRangeStart`/`CommentRangeEnd` for comments (which carries the comment's\nown metadata on its `Start`, since there's no wrapping case here to hang it off - see\n[MAPPING.md](MAPPING.md) on why that id is write-time-only, unlike a bookmark's own name):\n\n```fsharp\ndocument\n    [ section\n          [ para [ BookmarkRangeStart \"Section2\"; run \"This paragraph starts the bookmark\" ]\n            para [ run \"and this one ends it.\"; BookmarkRangeEnd \"Section2\" ] ] ]\n\ndocument\n    [ section\n          [ para [ CommentRangeStart(\"review1\", \"Alex\", None, None, \"This section needs review.\"); run \"Comment starts here\" ]\n            para [ run \"and ends here.\"; CommentRangeEnd \"review1\" ] ] ]\n```\n\nTrack changes (`inserted`/`deleted`) wrap inline content the same way, marking it as\ninserted or deleted under an author and date; a whole inserted or deleted paragraph\n(rather than just some of its content) uses `para`'s own `markRevision` instead, for the\nparagraph's closing mark:\n\n```fsharp\npara\n    [ run \"The quick \"\n      inserted ([ run \"brown \" ], \"Alex\")\n      run \"fox jumps over the \"\n      deleted ([ run \"lazy \" ], \"Alex\")\n      run \"dog.\" ]\n\npara ([ run \"This whole paragraph was inserted.\" ], markRevision = { Kind = Inserted; Author = \"Alex\"; Date = None })\n```\n\nContent controls (`contentControl` for run-level, `contentControlBlock` for block-level)\nwrap their own currently-displayed content the same way, plus a `ContentControlType` (see\n[MAPPING.md](MAPPING.md) for the full set - plain text, rich text, dropdown/combo box, date\npicker, checkbox):\n\n```fsharp\npara\n    [ run \"Client name: \"\n      contentControl ([ run \"Type here\" ], PlainTextControl false, alias = \"Client Name\", tag = \"clientName\") ]\n\npara\n    [ run \"Favorite color: \"\n      contentControl ([ run \"Blue\" ], DropDownControl([ \"Red\", \"red\"; \"Green\", \"green\"; \"Blue\", \"blue\" ], false)) ]\n\ncontentControlBlock([ para [ run \"This whole paragraph is a rich-text content control.\" ] ], RichTextControl, alias = \"Notes\")\n```\n\nDocument-level protection, macros, and core properties are all pipe-friendly, same shape as\nExcel's own `withProtection`/`withVbaProject`:\n\n```fsharp\ndocument [...] |> withProtection { Edit = Some ReadOnlyRestriction; Password = Some \"hunter2\" }\ndocument [...] |> withVbaProject (System.IO.File.ReadAllBytes(\"vbaProject.bin\"))\ndocument [...] |> withDocumentProperties { DocumentProperties.Default with Title = Some \"Quarterly Report\"; Author = Some \"Kookerella\" }\n```\n\nSave the result with a `.docm` path - `Document.save`/`saveToStream` automatically switch\nthe file's own declared content type to Word's macro-enabled kind whenever a `VbaProject`\nis present, but real Word also expects the `.docm` extension to trust and run macros at all.\n\n## Regenerating a file as F# source\n\nGiven a `Document` (typically one you just `Document.load`ed from an existing file),\n`Document.generateScript` renders it back out as a self-contained `.fsx` script that\nrebuilds an equivalent file when run - a code-generating counterpart to `Document.load`:\n\n```fsharp\nlet doc = Document.load \"input.docx\"\n\nlet referenceLines =\n    [ \"#r \\\"path/to/Kookerella.FsWordDsl.dll\\\"\"\n      \"#r \\\"path/to/DocumentFormat.OpenXml.dll\\\"\" ]\n\nlet script = Document.generateScript referenceLines \"output.docx\" doc\nSystem.IO.File.WriteAllText(\"regenerate.fsx\", script)\n```\n\nRunning `dotnet fsi regenerate.fsx` produces `output.docx` - not byte-identical to the\noriginal (zip metadata/timestamps differ) but structurally equivalent through the same\nround-trip lens every other test in this repo uses. Every scenario under `tests/\nKookerella.FsWordDsl.Tests/Examples/` has a committed `script.fsx` generated exactly this\nway; the `Category=Slow` test group actually executes each one via `dotnet fsi` and checks\nit reproduces the committed `.docx`.\n\n## The C# wrapper\n\n`Kookerella.CsWordDsl` is an idiomatic, immutable, fluent C# wrapper over the F# core, for\ncallers who'd rather not touch F# discriminated unions or option types directly. Every F#\ntype has a C# mirror: plain records with `With*`/factory-method builders for product types,\n`enum`s for parameterless choices, and `sealed record` closed hierarchies (`abstract record`\nbase, private constructor, nested cases) for everything else - the same \"sealed hierarchy\"\npattern the Excel repo's own `Kookerella.CsOpenXmlDsl` uses for `CellValue`/\n`ConditionalFormatRule`. Reference `Kookerella.CsWordDsl` instead of `Kookerella.FsWordDsl`\nand never see an `FSharpOption`:\n\n```csharp\nusing Kookerella.CsWordDsl;\n\nvar doc = Document.Create(\n    Section.Of([\n        Block.Paragraph([new Inline.Run(\"Quarterly Report\")], styleId: \"Title\"),\n        Block.Paragraph([\n            new Inline.Run(\"This report covers \"),\n            new Inline.Run(\"Q1 2026\", new RunStyle { Bold = true }),\n            Inline.HyperlinkText(\"full dataset\", new HyperlinkTarget.ExternalUrl(\"https://example.com/data\")),\n            new Inline.Run(\" for details.\")\n        ])\n    ]));\n\nDocumentIO.Save(doc, \"report.docx\");\nvar loaded = DocumentIO.Load(\"report.docx\");\n```\n\nContent controls, tables, track changes, comments, and every other feature the F# core\nmodels are covered the same way - see `tests/Kookerella.CsWordDsl.Tests/DocumentTests.cs`\nfor a worked example per feature. `CsCodeGen.Generate` is the C# analog of\n`Document.generateScript`: it renders a `Document` back out as a self-contained C# file\ntargeting .NET's \"file-based apps\" feature (`dotnet run --file script.cs`), rather than an\n`.fsx` script:\n\n```csharp\nvar script = CsCodeGen.Generate([\"#:project path/to/Kookerella.CsWordDsl.csproj\"], \"output.docx\", loaded);\nFile.WriteAllText(\"regenerate.cs\", script);\n```\n\n`DocumentIO` also exposes the F# core's other two ways in and out directly - schema-backed\nXML/JSON (`ToXml`/`FromXml`, `ToJson`/`FromJson`) and F# script generation\n(`GenerateFSharpScript`, `CsCodeGen.Generate`'s F#-targeting sibling) - so a C# caller never\nneeds its own reference to `Kookerella.FsWordDsl` to reach any of the F# core's four I/O\nsurfaces from C#.\n\nOne design note worth stating explicitly: this wrapper's records use `IReadOnlyList<T>`\nproperties, and C#'s compiler-synthesized record equality does not deep-compare list\ncontents (two records holding equal-but-distinct list instances compare unequal via plain\n`.Equals()`) - the same limitation `Kookerella.CsOpenXmlDsl`'s own records have. Don't rely\non whole-`Document` equality in your own code; compare the specific values you care about,\nthe same way this repo's own `DocumentTests.cs` does.\n\n## XML\n\n`Xml.toDocument`/`Xml.ofDocument` (in `Xml.fs`) are a third way in and out of the DSL,\nalongside writing F# directly and code generation: plain XML, against a real schema\n(`Xml.xsd`, embedded in the assembly). A data-carrying DU case becomes an element named\nafter the case; a parameterless-choice case becomes an attribute value or bare string,\nmatching the convention the Excel repo's own `Xml.fs` documents.\n\n```fsharp\nopen System.Xml.Linq\n\n// XML -> Document -> .docx\nlet doc = XElement.Load \"report.xml\" |> Xml.ofDocument\nDocument.save \"report.docx\" doc\n\n// .docx -> Document -> XML\nlet xml = Document.load \"report.docx\" |> Xml.toDocument\nxml.Save \"report.xml\"\n```\n\nA run with direct formatting and a hyperlink, in XML:\n\n```xml\n<para>\n  <run>Visit </run>\n  <hyperlink tooltip=\"Kookerella on GitHub\">\n    <externalHyperlink>https://github.com/Kookerella-Ltd</externalHyperlink>\n    <content>\n      <run styleId=\"Hyperlink\">Kookerella on GitHub</run>\n    </content>\n  </hyperlink>\n  <run> for more.</run>\n</para>\n```\n\n`Xml.schemaSet()` loads the compiled schema for validating either direction yourself\n(`XDocument.Validate`) - every scenario under `tests/Kookerella.FsWordDsl.Tests/Examples/`\nhas a committed `document.xml` validated against it this way as part of the same test that\ngenerates it.\n\n`toDocument`'s output is deterministically ordered (`Styles`/`Numbering`/`TableStyles` sorted\nby `Id`, regardless of the order the underlying `Document`'s own lists happen to be in) -\nparagraph/run content is already real document order and needs no sorting, but these three\nare ID-referenced catalogs whose own list order carries no meaning, so this is what makes\ncommitting `document.xml` to version control and diffing it across commits actually\nmeaningful: a genuine content change produces a small, isolated diff rather than a spurious\none from a catalog getting reshuffled between two otherwise-identical documents.\n\n## JSON\n\n`Json.toDocument`/`Json.ofDocument` (in `Json.fs`) are a fourth way in and out of the DSL,\nalongside writing F# directly, code generation, and XML: plain JSON, for a caller whose\ntooling speaks JSON rather than XML. The same DU-case conventions apply, in JSON's own\nidiom (a single-key object for a data-carrying case, a bare string for a parameterless one):\n\n```fsharp\nopen System.Text.Json.Nodes\n\n// JSON -> Document -> .docx\nlet doc = JsonNode.Parse(File.ReadAllText \"report.json\").AsObject() |> Json.ofDocument\nDocument.save \"report.docx\" doc\n\n// .docx -> Document -> JSON\nlet json = Document.load \"report.docx\" |> Json.toDocument\nFile.WriteAllText(\"report.json\", json.ToJsonString())\n```\n\nThe same hyperlink example as above, in JSON:\n\n```json\n{\n  \"para\": {\n    \"inlines\": [\n      { \"run\": { \"text\": \"Visit \" } },\n      {\n        \"hyperlink\": {\n          \"target\": { \"externalHyperlink\": \"https://github.com/Kookerella-Ltd\" },\n          \"runs\": [ { \"run\": { \"text\": \"Kookerella on GitHub\", \"styleId\": \"Hyperlink\" } } ],\n          \"tooltip\": \"Kookerella on GitHub\"\n        }\n      },\n      { \"run\": { \"text\": \" for more.\" } }\n    ]\n  }\n}\n```\n\nThe same determinism `Xml.toDocument` has (`Styles`/`Numbering`/`TableStyles` sorted by\n`Id`) applies to `Json.toDocument`'s output too, for the same reason: a genuine content\nchange produces a small, isolated diff rather than a spurious one from a catalog getting\nreshuffled between two otherwise-identical documents.\n\nUnlike XML, .NET has no built-in JSON Schema validator, so `Json.schema.json` (in the repo)\nis validated only from this repo's own test suite (via a test-only `JsonSchema.Net`\ndependency) rather than exposed as a public API - see `Json.fs`'s own doc comment.\n\n## Building and testing\n\n```bash\ndotnet build\ndotnet test --filter \"Category!=Slow\"\ndotnet run --project samples/Kookerella.FsWordDsl.Sample\n```\n\nThe default loop above skips the slow `Category=Slow` tests, which actually invoke\n`dotnet fsi` on every generated `Examples/*/script.fsx` (multi-second process startup each).\nRun those explicitly, after the fast suite has populated the `.fsx` files at least once:\n\n```bash\ndotnet test --filter \"Category=Slow\"\n```\n\nPlain `dotnet test` (no filter) runs both groups.\n\nThe C# wrapper's own suite has no fast/slow split - `CsCodeGenTests.cs` shells out to\n`dotnet run --file` itself, so a single run already covers the C# analog of the F# suite's\nslow group:\n\n```bash\ndotnet test tests/Kookerella.CsWordDsl.Tests\n```\n",
  "bytes": 29910,
  "sha": "be22e471d96fce45944fb8ce589617fb96cdc53abfa706b3ea4b31c98d0f459b",
  "repo_slug": "kookerella-ltd/kookerella.fsworddsl",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_marknicholls_fsworddsl_mcp_1161446f/readme"
}