Word tools for AI agents: read/write, decompile to F#/C#/XML/JSON, or build from XML/JSON.
Runs on your machine from its package; the steps are below.
At most: creates or changes data: records, files, settings.
Odel charges nothing per call. The publisher may charge for its own service.
Runs locally over stdio
This server isn't hosted — your MCP client launches it from a package registry. Use one of the commands below, or drop the config into your client (e.g. Claude Desktop).
Published to NuGet as `Kookerella.FsWordDsl.Mcp` — run it with your .NET MCP host.
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.
Don't have .NET installed? Download a self-contained build of the MCP server for your platform (win/linux/osx, x64/arm64) from the Releases page - unzip and run, no install required.
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.open Kookerella.FsWordDsl
open type Kookerella.FsWordDsl.DocumentDsl
let doc =
document
[ section
[ para ([ run "Quarterly Report" ], styleId = "Title")
para
[ run "This report covers "
run ("Q1 2026", style = { RunStyle.Default with Bold = true })
run ", see the "
hyperlink ("full dataset", ExternalUrl "https://example.com/data")
run " for details." ] ] ]
doc |> Document.save "report.docx"
// Reverse transform:
let roundTripped = Document.load "report.docx"
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:
para
[ run "Plain text, "
run ("bold", style = { RunStyle.Default with Bold = true })
run ", and "
run ("colored", style = { RunStyle.Default with Color = Some Color.red }) ]
RunStyle also covers small caps, all caps, and hidden text; ParagraphFormat covers
borders (BorderStyle, the same shape used for table borders) and shading:
para
([ run "ALL CAPS AND SMALL CAPS" ], format =
{ ParagraphFormat.Default with
Borders = Some { BorderStyle.None with Bottom = Some { Style = SingleLine; Width = Some 1.0; Color = Some Color.black } }
Shading = Some(Rgb(0xD9uy, 0xD9uy, 0xD9uy)) })
Custom tab stops (TabStop) sit on ParagraphFormat.TabStops - a right-aligned stop with a
dot leader is the classic table-of-contents pattern:
para
([ run "Introduction"; Tab; run "1" ], format =
{ ParagraphFormat.Default with TabStops = [ { Position = 288.0; Alignment = RightTab; Leader = DotLeader } ] })
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:
run ("Accent-colored text", style = { RunStyle.Default with Color = Some(Theme(Accent1Theme, (0x1Fuy, 0x49uy, 0x7Duy), None, None)) })
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:
document
[ section
[ para ([ run "First bullet" ], numbering = (1, 0))
para ([ run "Second bullet" ], numbering = (1, 0)) ] ]
|> withNumbering [ bulletListDef 1 ]
document
[ section
[ para ([ run "First topic" ], numbering = (1, 0))
para ([ run "First subtopic" ], numbering = (1, 1))
para ([ run "Second topic" ], numbering = (1, 0)) ] ]
|> withNumbering [ multiLevelNumberedListDef 1 3 ]
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:
table (
[ tableRow [ tableCell [ para [ run "Item" ] ]; tableCell [ para [ run "Qty" ] ] ]
tableRow [ tableCell [ para [ run "Widgets" ] ]; tableCell [ para [ run "12" ] ] ] ],
[ 200.0; 100.0 ],
style = TableStyleRef.Default
)
Cell merging - horizontal (GridSpan) and vertical (RestartMerge/ContinueMerge) - are
independent and combine on the same cell, matching real Word:
tableCell ([ para [ run "Spans 2 columns" ] ], props = { TableCellProps.Default with GridSpan = Some 2 })
A cell's own margins override the table's default the same CellMargins shape covers both:
tableCell ([ para [ run "Extra padding" ] ], props = { TableCellProps.Default with Margins = Some { CellMargins.Default with Top = Some 8.0; Bottom = Some 8.0 } })
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:
let corporateStyle: TableStyleDefinition =
{ TableStyleDefinition.Default with
Id = "Corporate"
Name = "Corporate"
FirstRow =
{ TableStyleRegion.None with
RunFormat = Some { RunStyle.Default with Bold = true; Color = Some Color.white }
CellShading = Some(Rgb(0x4Fuy, 0x81uy, 0xBDuy)) }
LastRow = { TableStyleRegion.None with RunFormat = Some { RunStyle.Default with Italic = true } }
BandedRow = { TableStyleRegion.None with CellShading = Some(Rgb(0xDCuy, 0xE6uy, 0xF1uy)) } }
document
[ section
[ table (
[ tableRow ([ tableCell [ para [ run "Item" ] ]; tableCell [ para [ run "Qty" ] ] ], repeatAsHeader = true)
tableRow [ tableCell [ para [ run "Widgets" ] ]; tableCell [ para [ run "12" ] ] ] ],
[ 200.0; 100.0 ],
style = { TableStyleRef.Default with Name = "Corporate" },
cellMargins = { Top = Some 4.0; Bottom = Some 4.0; Left = Some 6.0; Right = Some 6.0 }
) ] ]
|> withTableStyles [ corporateStyle ]
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:
let landscape = { SectionProperties.Default with Orientation = Landscape }
document [ sectionWith landscape [ para [ run "A landscape-oriented page." ] ] ]
let continuous = { SectionProperties.Default with BreakType = ContinuousBreak }
document
[ section [ para [ run "Section 1." ] ]
sectionWith continuous [ para [ run "Section 2 - no page break from section 1." ] ] ]
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):
para
[ run "This claim needs a citation"
footnote "Smith, J. (2023). A Study of Claims."
run ", and this one refers to a fuller discussion"
endnote [ para [ run "See the appendix for the full derivation." ] ] ]
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:
sectionWith
{ SectionProperties.Default with FootnoteNumbering = Some { Format = LowerRomanFormat; StartAt = None; Restart = RestartEachPage } }
[ para [ run "Body text."; footnote "A footnote numbered i, ii, iii, ... restarting each page." ] ]
Headers and footers are per-section, with Default/First/Even variants (the
titlePg/evenAndOddHeaders flags real Word needs are set automatically):
let footer = { HeaderFooterSet.None with Default = Some [ para [ run "Page "; Field("PAGE", Some "1") ] ] }
sectionWith { SectionProperties.Default with Footer = Some footer } [ para [ run "Body text." ] ]
Comments and bookmarks wrap inline content directly, the common single-paragraph case:
para [ comment ([ run "This figure needs review." ], "Please double check the totals.", author = "Alex") ]
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):
document
[ section
[ para [ BookmarkRangeStart "Section2"; run "This paragraph starts the bookmark" ]
para [ run "and this one ends it."; BookmarkRangeEnd "Section2" ] ] ]
document
[ section
[ para [ CommentRangeStart("review1", "Alex", None, None, "This section needs review."); run "Comment starts here" ]
para [ run "and ends here."; CommentRangeEnd "review1" ] ] ]
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:
para
[ run "The quick "
inserted ([ run "brown " ], "Alex")
run "fox jumps over the "
deleted ([ run "lazy " ], "Alex")
run "dog." ]
para ([ run "This whole paragraph was inserted." ], markRevision = { Kind = Inserted; Author = "Alex"; Date = None })
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):
para
[ run "Client name: "
contentControl ([ run "Type here" ], PlainTextControl false, alias = "Client Name", tag = "clientName") ]
para
[ run "Favorite color: "
contentControl ([ run "Blue" ], DropDownControl([ "Red", "red"; "Green", "green"; "Blue", "blue" ], false)) ]
contentControlBlock([ para [ run "This whole paragraph is a rich-text content control." ] ], RichTextControl, alias = "Notes")
Document-level protection, macros, and core properties are all pipe-friendly, same shape as
Excel's own withProtection/withVbaProject:
document [...] |> withProtection { Edit = Some ReadOnlyRestriction; Password = Some "hunter2" }
document [...] |> withVbaProject (System.IO.File.ReadAllBytes("vbaProject.bin"))
document [...] |> withDocumentProperties { DocumentProperties.Default with Title = Some "Quarterly Report"; Author = Some "Kookerella" }
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:
let doc = Document.load "input.docx"
let referenceLines =
[ "#r \"path/to/Kookerella.FsWordDsl.dll\""
"#r \"path/to/DocumentFormat.OpenXml.dll\"" ]
let script = Document.generateScript referenceLines "output.docx" doc
System.IO.File.WriteAllText("regenerate.fsx", script)
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:
using Kookerella.CsWordDsl;
var doc = Document.Create(
Section.Of([
Block.Paragraph([new Inline.Run("Quarterly Report")], styleId: "Title"),
Block.Paragraph([
new Inline.Run("This report covers "),
new Inline.Run("Q1 2026", new RunStyle { Bold = true }),
Inline.HyperlinkText("full dataset", new HyperlinkTarget.ExternalUrl("https://example.com/data")),
new Inline.Run(" for details.")
])
]));
DocumentIO.Save(doc, "report.docx");
var loaded = DocumentIO.Load("report.docx");
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:
var script = CsCodeGen.Generate(["#:project path/to/Kookerella.CsWordDsl.csproj"], "output.docx", loaded);
File.WriteAllText("regenerate.cs", 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.
open System.Xml.Linq
// XML -> Document -> .docx
let doc = XElement.Load "report.xml" |> Xml.ofDocument
Document.save "report.docx" doc
// .docx -> Document -> XML
let xml = Document.load "report.docx" |> Xml.toDocument
xml.Save "report.xml"
A run with direct formatting and a hyperlink, in XML:
<para>
<run>Visit </run>
<hyperlink tooltip="Kookerella on GitHub">
<externalHyperlink>https://github.com/Kookerella-Ltd</externalHyperlink>
<content>
<run styleId="Hyperlink">Kookerella on GitHub</run>
</content>
</hyperlink>
<run> for more.</run>
</para>
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):
open System.Text.Json.Nodes
// JSON -> Document -> .docx
let doc = JsonNode.Parse(File.ReadAllText "report.json").AsObject() |> Json.ofDocument
Document.save "report.docx" doc
// .docx -> Document -> JSON
let json = Document.load "report.docx" |> Json.toDocument
File.WriteAllText("report.json", json.ToJsonString())
The same hyperlink example as above, in JSON:
{
"para": {
"inlines": [
{ "run": { "text": "Visit " } },
{
"hyperlink": {
"target": { "externalHyperlink": "https://github.com/Kookerella-Ltd" },
"runs": [ { "run": { "text": "Kookerella on GitHub", "styleId": "Hyperlink" } } ],
"tooltip": "Kookerella on GitHub"
}
},
{ "run": { "text": " for more." } }
]
}
}
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.
dotnet build
dotnet test --filter "Category!=Slow"
dotnet run --project samples/Kookerella.FsWordDsl.Sample
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:
dotnet test --filter "Category=Slow"
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:
dotnet test tests/Kookerella.CsWordDsl.Tests