The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Word MCP Server (FsWordDsl) listing page.
A typesafe F# DSL for building Word documents, interpreted into calls against the
DocumentFormat.OpenXml SDK. The DSL is a plain
data model (records/DUs with structural equality) - the interpreter (Writer) compiles it
to OOXML, and the reverse transform (Reader) parses an existing .docx back into the
same DSL.
This is the WordprocessingML sibling of Kookerella.FsOpenXmlDsl (the Excel/SpreadsheetML one) - same objectives, same round-trip philosophy, translated to Word's own document model. See MAPPING.md for exactly which WordprocessingML features map 1:1, which are approximated, and which aren't modeled yet.
This round-trips in both directions, which most Word libraries don't: they give you an
imperative API to build a document from scratch, but no way to turn an existing file back
into readable source. Here, Reader parses a real .docx/.docm back into the same DSL,
and Document.generateScript goes one step further and renders that model back out as a
self-contained script that rebuilds an equivalent file - a decompiler for Word documents,
not just a writer. Two more surfaces, Xml.toDocument/Xml.ofDocument (see "## XML"
below) and Json.toDocument/Json.ofDocument (see "## JSON" below), do the same
translation to/from plain XML or JSON against a real schema - for a caller who'd rather
generate or consume data than write code at all.
A fluent C# wrapper (Kookerella.CsWordDsl) sits on top of the F# core for callers who'd
rather not touch F# discriminated unions/option types directly - immutable records with
With* builders, plus its own CsCodeGen decompiler that renders a Document back out as
runnable C# source. See "## The C# wrapper" below.
An MCP server (Kookerella.FsWordDsl.Mcp) exposes the same read/write/decompile
capabilities as tools any MCP-compatible AI agent can call directly, and doubles as a plain
CLI (fsworddsl-mcp convert/build) for anyone not going through an MCP client at all -
see its own README for the full tool list.
src/Kookerella.FsWordDsl - the library.
Units.fs - conversions between points/inches/pixels and the physical units
WordprocessingML uses on the wire (twips for page geometry/spacing, EMU for image
sizing).Styles.fs - character and paragraph formatting: Color (Rgb, Auto, or a
theme-relative Theme color - see ThemeColorKind), HighlightColor (Word's own
fixed highlight palette), UnderlineStyle, RunStyle (including small caps/all
caps/hidden text), ParagraphAlignment, Indentation, LineSpacingRule,
TabStopAlignment/TabLeader/TabStop, ParagraphFormat (including paragraph
borders, shading, and custom tab stops), BorderLineStyle, BorderSide,
BorderStyle (reused for both paragraph and table borders).NamedStyles.fs - StyleDefinition (paragraph or character, with BasedOn
inheritance) and a small BuiltInStyles catalog (normal, heading1/2/3,
title, listParagraph, hyperlinkCharStyle).Numbering.fs - NumberFormatKind, ListLevel, NumberingDefinition for
numbered/bulleted lists, including multi-level ones (ListLevel isn't limited to one
per definition - see Builders.multiLevelNumberedListDef).Hyperlinks.fs - HyperlinkTarget (external URL vs. internal bookmark reference).Protection.fs - EditRestriction and DocumentProtection, document-level (Word has
no per-section equivalent of Excel's per-sheet protection).Revisions.fs - RevisionKind/Revision for track changes (Inline.TrackedChange,
Paragraph.MarkRevision) - narrowly scoped to inserted/deleted content and paragraph
marks, see MAPPING.md for what isn't covered.ContentControls.fs - ContentControlType/ContentControlProps for content controls
(structured document tags, w:sdt): plain text, rich text, dropdown/combo box, date
picker, checkbox - see MAPPING.md for what isn't covered.PageSetup.fs - PageOrientation, PageSize, PageMargins, SectionBreakType,
NoteNumberRestart/NoteNumberingSettings (a section's own footnote/endnote
numbering).Tables.fs - TableBorders, VerticalMergeKind, TableCellProps (including a
per-cell Margins override), TableStyleRef, TableStyleRegion/
TableStyleDefinition (custom table style definitions - all thirteen of OOXML's
conditional-formatting regions), and CellMargins (shared shape for a table's default
margins and a single cell's own override).Images.fs - ImageFormat, ImageEntry (raw file bytes plus an on-page size),
anchored inline within a run.DocumentProperties.fs - DocumentProperties (Title, Author, Subject, Keywords,
Comments, Category, Company) - core document metadata, Document.Properties.Model.fs - the recursive content model: Inline (runs, breaks, images, hyperlinks,
bookmarks and comments - both the single-paragraph Bookmark/Comment cases and the
cross-paragraph BookmarkRangeStart/End/CommentRangeStart/End markers, simple
fields, footnotes/endnotes, TrackedChange for track changes, and
InlineContentControl for content controls), Paragraph (including MarkRevision),
Block (paragraph, table, or ContentControlBlock - the block-level counterpart to
InlineContentControl), TableCell/
TableRow (including RepeatAsHeader)/TableEntry (including CellMargins),
HeaderFooterSet,
SectionProperties (including BreakType and FootnoteNumbering/
EndnoteNumbering), Section, Document (including Document.VbaProject, a
macro-enabled document's raw vbaProject.bin bytes, Document.Properties, and
Document.TableStyles).Xml.fs / Xml.xsd - the XML surface: Xml.toDocument/Xml.ofDocument translate a
Document to/from an XElement tree, and Xml.schemaSet() loads the paired schema
(embedded in the assembly as a resource) for validating either direction. See
"## XML" below.Json.fs / Json.schema.json - the JSON surface: Json.toDocument/Json.ofDocument
translate a Document to/from a System.Text.Json.Nodes.JsonObject tree. Schema
validation is test-suite only, not a public API. See "## JSON" below.Builders.fs - plain functional constructors (section, document, withStyles,
withNumbering, withProtection, withVbaProject, withDocumentProperties,
withTableStyles, bulletListDef, numberedListDef, multiLevelNumberedListDef)
plus DocumentDsl - smart constructors (run, para (with markRevision),
hyperlink, bookmark, comment, inserted/deleted (track changes),
contentControl/contentControlBlock (content controls), image, footnote,
endnote, tableCell, tableRow (with height/repeatAsHeader), table (with
style/borders/cellMargins)) with real optional parameters, the Word analog of
the Excel repo's SheetDsl.Interpreter/StyleRegistry.fs - shared run/paragraph/border/color conversions plus
Document.Styles <-> styles.xml (internal).Interpreter/ImageWriter.fs / ImageReader.fs - an inline image's own DSL <->
DrawingML translation (internal).Interpreter/Writer.fs - DSL -> OOXML (internal).Interpreter/Reader.fs - OOXML -> DSL, the reverse transform (internal).Interpreter/CodeGen.fs - DSL -> F# source text: renders a Document back out as a
self-contained .fsx script that rebuilds an equivalent file when run (internal).Api.fs - the public Document.save/saveToStream/load/loadFromStream/
generateScript entry points.src/Kookerella.CsWordDsl - the fluent C# wrapper (see "## The C# wrapper"
below): immutable records/sealed record closed hierarchies mirroring the F# core's own
types one-for-one, DocumentConverter.cs (internal, the two-way F#<->C# translation),
DocumentIO.cs (Save/Load, the one place this project does I/O), CsCodeGen.cs (DSL
-> C# source text, the C# analog of Interpreter/CodeGen.fs).src/Kookerella.FsWordDsl.Mcp - the MCP server (see its own README):
DocumentTools.fs (the tool surface, one [<McpServerTool>]-tagged member per tool),
Program.fs (dispatches to the MCP stdio server, or to a plain convert/build CLI,
depending on argv). Distributed as a dotnet tool (fsworddsl-mcp), same as the Excel
sibling's own Kookerella.FsOpenXmlDsl.Mcp.tests/Kookerella.FsWordDsl.Tests - one test per feature, each validating the produced
file against the OOXML schema (DocumentFormat.OpenXml.Validation.OpenXmlValidator) and
asserting an exact round trip back through the DSL. Each test also writes the document it
builds to Examples/<test name>/output.docx (checked into the repo), plus script.fsx
(regenerates the file - a separate, slower Category=Slow test group actually executes
each one via dotnet fsi), document.xml, and document.json - one folder always has
four views of the same example.tests/Kookerella.CsWordDsl.Tests - DriftGuardTests.cs (a reflection-based tripwire
checking the C# wrapper's DU mirrors haven't fallen behind the F# core's own case
counts), DocumentTests.cs (targeted round-trip assertions per feature),
ExampleTests.cs (reloads the F# suite's own checked-in Examples/*/output.docx
fixtures rather than re-authoring every scenario a second time), CsCodeGenTests.cs
(actually executes a generated file via dotnet run --file, the C# analog of the F#
suite's Category=Slow dotnet fsi group).samples/Kookerella.FsWordDsl.Sample - a small console app that builds a document, saves
it, and reads it back.document defaults Styles to BuiltInStyles.all, so styleId = "Heading1" (or any other
built-in id) just works without registering it first - pipe withStyles afterward to
replace or extend that set. run/para/hyperlink/bookmark/comment/image/
tableCell/tableRow/table are DocumentDsl members with real optional parameters
(open type Kookerella.FsWordDsl.DocumentDsl brings them into scope unqualified, same as
open type SheetDsl does in the Excel repo) - plain F# let bindings can't have optional
parameters, which is why this part of the DSL is a type.
A Paragraph's Inlines are naturally several independently-styled runs - rich text
(mixed formatting within one paragraph) is first-class, not a documented gap the way
Excel's single-uniform-run Text cell is:
RunStyle also covers small caps, all caps, and hidden text; ParagraphFormat covers
borders (BorderStyle, the same shape used for table borders) and shading:
Custom tab stops (TabStop) sit on ParagraphFormat.TabStops - a right-aligned stop with a
dot leader is the classic table-of-contents pattern:
Color also accepts a theme-relative token (Theme) alongside plain Rgb/Auto - since
this DSL has no theme part to resolve it against, real Word does that; Fallback is what a
themeless reader sees instead, the same "always also write a computed value" convention Word
itself follows:
Lists use a (numId, level) reference on the paragraph, resolved against a
NumberingDefinition attached to the document - NumberingDefinition.Levels isn't limited
to one level, and multiLevelNumberedListDef builds the common correctly-linked outline
shape for you:
Tables are built from tableRow/tableCell, with column widths given once for the whole
table - a cell without an explicit width falls back to its column's width at write time:
Cell merging - horizontal (GridSpan) and vertical (RestartMerge/ContinueMerge) - are
independent and combine on the same cell, matching real Word:
A cell's own margins override the table's default the same CellMargins shape covers both:
A custom table style (TableStyleDefinition) lives in Document.TableStyles and is applied
by name, the same way a built-in like "TableGrid" is - here with a bold white header row on
a blue background, an italic last row, and alternating row shading, plus a table-wide default
cell margin and a row that repeats on every page:
TableStyleDefinition also covers FirstColumn/LastColumn, BandedColumn, and the four
corner cells (NorthEastCell/NorthWestCell/SouthEastCell/SouthWestCell) - the two
regions not modeled are each banding axis's second band, since in practice that's just
WholeTable's own background showing through (see MAPPING.md).
Sections carry their own page setup - a document is a sequence of Sections, mapping 1:1
onto real Word section breaks. BreakType is how a section begins relative to the
previous one - meaningless (and not written) on the very first section:
Footnotes and endnotes mark a point in a paragraph's own Inlines - content is the
note's own body, written to word/footnotes.xml/endnotes.xml with an id Writer assigns
automatically (the reference-mark run itself is generated for you, on both ends):
A section's own footnote/endnote numbering (w:footnotePr/w:endnotePr) - None is Word's
own default (continuous decimal from 1); here footnotes are lower-roman and restart every
page, matching a common legal-document convention:
Headers and footers are per-section, with Default/First/Even variants (the
titlePg/evenAndOddHeaders flags real Word needs are set automatically):
Comments and bookmarks wrap inline content directly, the common single-paragraph case:
Either spanning more than one paragraph uses two independent markers placed directly in
separate paragraphs instead, sharing an id - BookmarkRangeStart/BookmarkRangeEnd for
bookmarks, CommentRangeStart/CommentRangeEnd for comments (which carries the comment's
own metadata on its Start, since there's no wrapping case here to hang it off - see
MAPPING.md on why that id is write-time-only, unlike a bookmark's own name):
Track changes (inserted/deleted) wrap inline content the same way, marking it as
inserted or deleted under an author and date; a whole inserted or deleted paragraph
(rather than just some of its content) uses para's own markRevision instead, for the
paragraph's closing mark:
Content controls (contentControl for run-level, contentControlBlock for block-level)
wrap their own currently-displayed content the same way, plus a ContentControlType (see
MAPPING.md for the full set - plain text, rich text, dropdown/combo box, date
picker, checkbox):
Document-level protection, macros, and core properties are all pipe-friendly, same shape as
Excel's own withProtection/withVbaProject:
Save the result with a .docm path - Document.save/saveToStream automatically switch
the file's own declared content type to Word's macro-enabled kind whenever a VbaProject
is present, but real Word also expects the .docm extension to trust and run macros at all.
Given a Document (typically one you just Document.loaded from an existing file),
Document.generateScript renders it back out as a self-contained .fsx script that
rebuilds an equivalent file when run - a code-generating counterpart to Document.load:
Running dotnet fsi regenerate.fsx produces output.docx - not byte-identical to the
original (zip metadata/timestamps differ) but structurally equivalent through the same
round-trip lens every other test in this repo uses. Every scenario under tests/ Kookerella.FsWordDsl.Tests/Examples/ has a committed script.fsx generated exactly this
way; the Category=Slow test group actually executes each one via dotnet fsi and checks
it reproduces the committed .docx.
Kookerella.CsWordDsl is an idiomatic, immutable, fluent C# wrapper over the F# core, for
callers who'd rather not touch F# discriminated unions or option types directly. Every F#
type has a C# mirror: plain records with With*/factory-method builders for product types,
enums for parameterless choices, and sealed record closed hierarchies (abstract record
base, private constructor, nested cases) for everything else - the same "sealed hierarchy"
pattern the Excel repo's own Kookerella.CsOpenXmlDsl uses for CellValue/
ConditionalFormatRule. Reference Kookerella.CsWordDsl instead of Kookerella.FsWordDsl
and never see an FSharpOption:
Content controls, tables, track changes, comments, and every other feature the F# core
models are covered the same way - see tests/Kookerella.CsWordDsl.Tests/DocumentTests.cs
for a worked example per feature. CsCodeGen.Generate is the C# analog of
Document.generateScript: it renders a Document back out as a self-contained C# file
targeting .NET's "file-based apps" feature (dotnet run --file script.cs), rather than an
.fsx script:
DocumentIO also exposes the F# core's other two ways in and out directly - schema-backed
XML/JSON (ToXml/FromXml, ToJson/FromJson) and F# script generation
(GenerateFSharpScript, CsCodeGen.Generate's F#-targeting sibling) - so a C# caller never
needs its own reference to Kookerella.FsWordDsl to reach any of the F# core's four I/O
surfaces from C#.
One design note worth stating explicitly: this wrapper's records use IReadOnlyList<T>
properties, and C#'s compiler-synthesized record equality does not deep-compare list
contents (two records holding equal-but-distinct list instances compare unequal via plain
.Equals()) - the same limitation Kookerella.CsOpenXmlDsl's own records have. Don't rely
on whole-Document equality in your own code; compare the specific values you care about,
the same way this repo's own DocumentTests.cs does.
Xml.toDocument/Xml.ofDocument (in Xml.fs) are a third way in and out of the DSL,
alongside writing F# directly and code generation: plain XML, against a real schema
(Xml.xsd, embedded in the assembly). A data-carrying DU case becomes an element named
after the case; a parameterless-choice case becomes an attribute value or bare string,
matching the convention the Excel repo's own Xml.fs documents.
A run with direct formatting and a hyperlink, in XML:
Xml.schemaSet() loads the compiled schema for validating either direction yourself
(XDocument.Validate) - every scenario under tests/Kookerella.FsWordDsl.Tests/Examples/
has a committed document.xml validated against it this way as part of the same test that
generates it.
toDocument's output is deterministically ordered (Styles/Numbering/TableStyles sorted
by Id, regardless of the order the underlying Document's own lists happen to be in) -
paragraph/run content is already real document order and needs no sorting, but these three
are ID-referenced catalogs whose own list order carries no meaning, so this is what makes
committing document.xml to version control and diffing it across commits actually
meaningful: a genuine content change produces a small, isolated diff rather than a spurious
one from a catalog getting reshuffled between two otherwise-identical documents.
Json.toDocument/Json.ofDocument (in Json.fs) are a fourth way in and out of the DSL,
alongside writing F# directly, code generation, and XML: plain JSON, for a caller whose
tooling speaks JSON rather than XML. The same DU-case conventions apply, in JSON's own
idiom (a single-key object for a data-carrying case, a bare string for a parameterless one):
The same hyperlink example as above, in JSON:
The same determinism Xml.toDocument has (Styles/Numbering/TableStyles sorted by
Id) applies to Json.toDocument's output too, for the same reason: a genuine content
change produces a small, isolated diff rather than a spurious one from a catalog getting
reshuffled between two otherwise-identical documents.
Unlike XML, .NET has no built-in JSON Schema validator, so Json.schema.json (in the repo)
is validated only from this repo's own test suite (via a test-only JsonSchema.Net
dependency) rather than exposed as a public API - see Json.fs's own doc comment.
The default loop above skips the slow Category=Slow tests, which actually invoke
dotnet fsi on every generated Examples/*/script.fsx (multi-second process startup each).
Run those explicitly, after the fast suite has populated the .fsx files at least once:
Plain dotnet test (no filter) runs both groups.
The C# wrapper's own suite has no fast/slow split - CsCodeGenTests.cs shells out to
dotnet run --file itself, so a single run already covers the C# analog of the F# suite's
slow group: