{
  "markdown": "# Kookerella.FsOpenXmlDsl\n\nA typesafe F# DSL for building Excel workbooks, 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 `.xlsx` back into the\nsame DSL.\n\nSee [MAPPING.md](MAPPING.md) for exactly which SpreadsheetML features map 1:1, which are\napproximated, and which aren't modeled yet.\n\n**This round-trips in both directions**, which most Excel libraries (EPPlus, ClosedXML,\nNPOI, ...) don't: they give you an imperative API to build a workbook from scratch or mutate\nan existing one, but no way to turn an *existing* file back into readable source. Here,\n`Reader` parses a real `.xlsx`/`.xlsm` back into the same DSL, and `Workbook.generateScript`\n(F#) / `CsCodeGen.Generate` (C#) go one step further and render that model back out as a\nself-contained script that rebuilds an equivalent file - a decompiler for spreadsheets, not\njust a writer. Two more surfaces, `Xml.ofWorkbook`/`Xml.toWorkbook` (see [\"## XML\"](#xml)\nbelow) and `Json.ofWorkbook`/`Json.toWorkbook` (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, e.g. an XSLT pipeline producing a report.\n`Kookerella.FsOpenXmlDsl.Mcp` exposes all four directions as MCP tools\n(`generate_fsharp_script`/`generate_csharp_script`/`generate_xml`/`create_workbook_from_xml`/\n`generate_json`/`create_workbook_from_json`) for an AI agent, and as `fsopenxmldsl-mcp\nconvert`/`build` CLI commands for anyone else - try it on any spreadsheet you already have,\nno code required:\n\n```bash\ndotnet tool install -g Kookerella.FsOpenXmlDsl.Mcp\nfsopenxmldsl-mcp convert your-file.xlsx --lang csharp\n```\n\n## Demos\n\nFull worked examples of the decompile-then-extend workflow above - reverse-engineering\nthe same invoice template into C#, F#, an XSLT transform, and a plain JSON-generation\nscript, each wired up to real data and real tests proving the result stays schema-valid -\nlive in a companion repo:\n[Kookerella.Demo.DecompileToSource](https://github.com/Kookerella-Ltd/Kookerella.Demo.DecompileToSource).\n\n## Layout\n\n- `src/Kookerella.FsOpenXmlDsl` — the library.\n  - `Reference.fs` — `CellRef` and `\"A1\"`-style address conversions.\n  - `Styles.fs` — cell formatting: `Color`, `FontStyle`, `FillStyle`, `BorderStyle`,\n    `AlignmentStyle`, `NumberFormat`, `CellProtection`, `CellStyle`.\n  - `Validation.fs` — conditional formatting and data validation: `ComparisonOperator`\n    (shared by both), `ConditionalFormatRule`, `ValidationKind`, `ValidationAlert`, and the\n    `ConditionalFormatEntry`/`DataValidationEntry` records stored on `Worksheet`.\n  - `Hyperlinks.fs` — `HyperlinkTarget` (external URL/`mailto:` vs. internal same-workbook\n    reference) and the `HyperlinkEntry` record stored on `Worksheet`.\n  - `Comments.fs` — `CommentEntry` (classic cell comments, i.e. current Excel's \"Notes\" -\n    see MAPPING.md for the modern threaded-comments gap).\n  - `Protection.fs` — `SheetProtection`, the sheet-level protection flags stored on\n    `Worksheet` (pairs with `CellStyle.Protection` for per-cell locking), and\n    `WorkbookProtection`, the workbook-level structure/window protection flags stored on\n    `Workbook`.\n  - `DefinedNames.fs` — `DefinedNameScope`/`DefinedNameEntry`, stored on `Workbook` rather\n    than `Worksheet` - the one DSL concept that's genuinely workbook-level.\n  - `PageSetup.fs` — print settings: `PageOrientation`, `PaperSize`, `PrintScaling`,\n    `PageMargins`, and the `PageSetup` record stored on `Worksheet`.\n  - `Tables.fs` — Excel Tables: `TableColumn`, `TableStyle`, and the `TableEntry` record\n    stored as a list on `Worksheet` (a sheet can have several).\n  - `Sparklines.fs` — in-cell mini-charts: `SparklineType`, `SparklineStyle`,\n    `SparklineCell`, and the `SparklineGroupEntry` record stored as a list on `Worksheet`\n    (a sheet can have several independently-styled groups).\n  - `Charts.fs` — column/bar/line/pie charts: `ChartType`, `ChartSeries`, and the\n    `ChartEntry` record stored as a list on `Worksheet` (a sheet can have several).\n  - `Images.fs` — raster images: `ImageFormat` and the `ImageEntry` record (raw file\n    bytes plus a cell-range anchor) stored as a list on `Worksheet`.\n  - `PivotTables.fs` — `PivotAggregation` and the `PivotTableEntry` record (source range,\n    row/column/value fields, an anchor cell) stored as a list on `Worksheet`.\n  - `Model.fs` — `CellValue`, `Cell`, `Worksheet`, `Workbook` (including `Workbook.\n    VbaProject`, a macro-enabled workbook's raw `vbaProject.bin` bytes - see its own doc\n    comment; there's no dedicated `Macros.fs` since it's a single opaque field, not a new\n    type).\n  - `Xml.fs` / `Xml.xsd` — the XML surface: `Xml.toWorkbook`/`Xml.ofWorkbook` translate a\n    `Workbook` 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` — the JSON surface: `Json.toWorkbook`/`Json.ofWorkbook` translate a `Workbook`\n    to/from a `System.Text.Json.Nodes.JsonObject` tree, covering the same\n    worksheet/workbook-level feature set `Xml.fs` does. Schema validation\n    (`Json.schema.json`) is test-suite only, not a public API - see [\"## JSON\"](#json)\n    below.\n  - `Builders.fs` — ergonomic helpers: plain functional constructors (`cellA1`, ...) for\n    the canonical model, plus the `SheetItem`/`CellEntry` types (each a single simple DU\n    case with optional fields) and the `sheet` fold function - a small tree-shaped \"AST\n    for building a sheet\" (rows of cells, plus sheet-level facts like column widths,\n    merges, conditional formats, data validations, hyperlinks, comments, autofilter, and\n    sheet protection) that mirrors how SpreadsheetML itself nests. `SheetDsl` is what you\n    actually write against: `cell`/`row`/`autoFilter`/`conditionalFormat`/\n    `dataValidation`/`hyperlink`/`comment` members with real optional parameters (`?col`,\n    `?style`, `?index`, the data validation alert fields, `?tooltip`, `?author`) - no\n    builder objects, no separate \"styled\" function, no `None`-noise for the common case.\n    (`Protect` is the one `SheetItem` case with no smart constructor - `SheetProtection`\n    is a plain record you build the usual F# way, `{ SheetProtection.Default with ... }`.)\n  - `Interpreter/StyleRegistry.fs` — interns fonts/fills/borders/number formats into a\n    shared OOXML stylesheet (internal).\n  - `Interpreter/ChartWriter.fs` / `ChartReader.fs` — charts' own DSL ↔ DrawingML/ChartML\n    translation, split out from `Writer.fs`/`Reader.fs` given how much larger that one\n    feature's OOXML surface is than everything else combined (internal).\n  - `Interpreter/ImageWriter.fs` / `ImageReader.fs` — images' own DSL ↔ DrawingML\n    translation (internal).\n  - `Interpreter/DrawingWriter.fs` / `DrawingReader.fs` — own the one `DrawingsPart`/\n    `<drawing>` relationship a worksheet gets when it has charts and/or images, since both\n    features share that one drawing canvas rather than each managing their own (internal).\n  - `Interpreter/PivotTableWriter.fs` / `PivotTableReader.fs` — pivot tables' own group-by\n    + aggregate engine plus DSL ↔ OOXML translation (`pivotCacheDefinition`/\n    `pivotCacheRecords`/`pivotTableDefinition`), split out from `Writer.fs`/`Reader.fs` the\n    same way charts and images are (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 `Workbook` back out as a\n    self-contained `.fsx` script that rebuilds an equivalent file when run (internal).\n  - `Api.fs` — the public `Workbook.save` / `saveToStream` / `load` / `loadFromStream` /\n    `generateScript` entry points.\n- `tests/Kookerella.FsOpenXmlDsl.Tests` — one test per feature, each validating the produced file\n  against the OOXML schema (`DocumentFormat.OpenXml.Validation.OpenXmlValidator`) and\n  asserting an exact round trip back through the DSL. Each test also writes the workbook\n  it builds to `Examples/<test name>/output.xlsx` (checked into the repo), so every\n  feature has a real, openable `.xlsx` demonstrating it - a browsable gallery, not just\n  assertions. Each scenario also gets an `Examples/<test name>/script.fsx` - see\n  \"Regenerating a file as F# source\" below - which a separate, slower `Category=Slow` test\n  group actually executes via `dotnet fsi` and verifies against the committed `.xlsx`, and\n  an `Examples/<test name>/workbook.xml` - the same workbook through `Xml.ofWorkbook`,\n  validated against `Xml.xsd` at generation time (see \"## XML\" below) - and an\n  `Examples/<test name>/workbook.json` - the same workbook through `Json.ofWorkbook`,\n  validated against `Json.schema.json` at generation time (see \"## JSON\" below) - so one\n  folder always has four views of the same example: the real file, the F# source that\n  rebuilds it, and the XML/JSON that also rebuild it.\n  `Assets/` holds the one test fixture too large to inline as a base64 literal like every\n  other binary fixture in `Tests.fs` - a real `vbaProject.bin` extracted from a workbook\n  actually saved by Excel, used by the macro example.\n- `samples/Kookerella.FsOpenXmlDsl.Sample` — a small console app that builds a workbook, saves it,\n  and reads it back.\n- `src/Kookerella.CsOpenXmlDsl` — an idiomatic, immutable, fluent C# wrapper over this\n  library, for callers who'd rather not touch F# discriminated unions/option types\n  directly. Now covers every feature this library models at the worksheet/workbook level -\n  see its own README for scope and an example. `tests/Kookerella.CsOpenXmlDsl.Tests` is its\n  own C# xUnit suite, exercising the wrapper the way a real C# caller would rather than\n  reusing the F# test project.\n- `src/Kookerella.FsOpenXmlDsl.Mcp` — a local MCP (Model Context Protocol) server exposing\n  this library's read/write/code-generation/XML/JSON capabilities as tools any\n  MCP-compatible AI agent can call directly, and the same conversion capability as plain\n  `fsopenxmldsl-mcp convert`/`build` CLI commands for anyone not going through an MCP\n  client - see its own README for the tool list and how to configure it.\n\n## Quick start\n\n```fsharp\nopen Kookerella.FsOpenXmlDsl\nopen type Kookerella.FsOpenXmlDsl.SheetDsl\n\nlet headerStyle =\n    { CellStyle.Default with\n        Font = Some { FontStyle.Default with Bold = true }\n        Fill = Some { Color = Rgb(220uy, 220uy, 220uy) } }\n\nlet data =\n    sheet\n        \"Sheet1\"\n        [ row [ cell (Text \"Name\", style = headerStyle)\n                cell (Text \"Amount\", style = headerStyle) ]\n          row [ cell (Text \"Widgets\")\n                cell (Number 42.5, style = { CellStyle.Default with NumberFormat = Some TwoDecimal }) ]\n          Freeze(1, 0) ]\n\nworkbook [ data ] |> Workbook.save \"out.xlsx\"\n\n// Reverse transform:\nlet roundTripped = Workbook.load \"out.xlsx\"\n```\n\n`CellEntry` and `SheetItem`'s row case are each a single simple DU case with optional\nfields (`Col`/`Index`) rather than separate \"styled\" or \"explicit position\" cases - `None`\nmeans \"the next column/row after the previous entry\" (starting at 0), `Some n` jumps there\nexplicitly and sequential numbering resumes right after it. You don't construct the case\ndirectly, though: `SheetDsl.cell`/`SheetDsl.row` are members with real optional\nparameters (`?col`/`?style` on `cell`, `?index` on `row`) that hide the `None`s for\nthe common case - plain `let` functions can't have optional parameters in F#, which is why\nthis one bit of the DSL is a type. `open type Kookerella.FsOpenXmlDsl.SheetDsl` (alongside `open Kookerella.FsOpenXmlDsl`) brings `cell`/`row`\ninto scope unqualified, same as a module. Explicit column/row jumps go through the same\ntwo members, just with the optional argument supplied: `cell (value, col = 2)` and\n`row (cells, index = 4)`. `sheet` is the one fold that interprets the resulting item\nlist into the canonical `Worksheet` (the same relationship `Writer` has to OOXML). If you\nalready have cells pre-addressed by `CellRef` rather than grouped by row, `sheetOfCells`\nbuilds a `Worksheet` directly from a flat `Cell list` instead.\n\n**A `Formula` cell is `Formula(expression, cachedValue: float option)` - this library never\nevaluates formulas itself, so `cachedValue` is the only number that will ever exist for that\ncell until something else computes one.** Real Excel recalculates on open and overwrites it,\nso leaving it `None` is fine if a human always opens the result in Excel first. It's *not*\nsafe for a headless pipeline - e.g. generating a workbook and piping it straight into a PDF\nconverter, another automated reader, or anything else that never opens it in real Excel.\nWhether that downstream step shows a correct number, a blank, or a stale one depends\nentirely on whether *it* happens to have its own formula engine; some do (Aspose.Cells,\nSyncfusion, GemBox, real Excel via COM), many lighter-weight or headless converters don't and\nwill just render whatever's already in the cell. Since you already have the numbers that fed\ninto the formula, always pass the real result as `cachedValue` for anything that isn't\nguaranteed to pass through Excel first - it costs nothing and sidesteps the problem\nentirely, since a downstream reader with no evaluator at all can still show a correct value\nsomeone else already computed.\n\nConditional formatting and data validation are `SheetItem`s too:\n\n```fsharp\n[ conditionalFormat (\n    CellRef.ofA1 \"A1\",\n    CellRef.ofA1 \"A10\",\n    CellValueRule(GreaterThan, \"100\", None, { CellStyle.Default with Fill = Some { Color = Rgb(255uy, 199uy, 206uy) } })\n  )\n  dataValidation (CellRef.ofA1 \"B1\", CellRef.ofA1 \"B10\", ListValidation [ \"Small\"; \"Medium\"; \"Large\" ]) ]\n```\n\nSee [MAPPING.md](MAPPING.md) for exactly which rule kinds of each are covered.\n\nDefined names are workbook-level, so they attach to the `Workbook`, not a `Worksheet`:\n\n```fsharp\nworkbook [ data ]\n|> withDefinedNames\n    [ definedName \"TaxRate\" \"Sheet1!$A$1\"\n      sheetScopedDefinedName \"Sheet1\" \"LocalTotal\" \"Sheet1!$A$2\" ]\n```\n\nWorkbook-level protection (as distinct from a `Worksheet`'s own `SheetProtection`) is\nalso workbook-level, same pipe-friendly shape:\n\n```fsharp\nworkbook [ data ]\n|> withProtection { WorkbookProtection.Default with LockStructure = Some true }\n```\n\n`withDefinedNames`/`withProtection` compose - pipe both onto the same `workbook [...]`.\n\nMacros are also workbook-level, same pipe-friendly shape - `withVbaProject` takes the raw\nbytes of an existing `vbaProject.bin` (extracted from an `.xlsm` you already have, e.g. via\n`System.IO.Compression.ZipFile`, or authored in Excel's VBA editor and harvested the same\nway). Core doesn't decode, generate, or otherwise understand VBA source - it embeds and\nreads back exactly the bytes you give it, the same \"opaque payload\" treatment\n`ImageEntry.Data` gets for raster images:\n\n```fsharp\nworkbook [ data ]\n|> withVbaProject (System.IO.File.ReadAllBytes(\"vbaProject.bin\"))\n```\n\nSave the result with an `.xlsm` path - `Workbook.save`/`saveToStream` automatically switch\nthe file's own declared content type to Excel's macro-enabled kind whenever a `VbaProject`\nis present, but real Excel also expects the `.xlsm` extension to trust and run macros at\nall. See [MAPPING.md](MAPPING.md) for what isn't modeled (authoring macro source, and the\none case where the default sheet/workbook codenames Core writes won't match what a macro's\noriginal author intended).\n\nPrint settings are a `SheetItem` too - `PageSetup` (the DU case) takes a plain\n`PageSetup` record (the type), no smart constructor, same as `Protect`/`SheetProtection`.\n`PrintArea` is a list of ranges (Excel supports several disjoint print rectangles per\nsheet) - under the hood it's actually a hidden defined name, but `Writer`/`Reader`\ntranslate transparently, so it reads and writes like any other `PageSetup` field:\n\n```fsharp\n[ PageSetup\n    { PageSetup.Default with\n        Orientation = Landscape\n        Scaling = Some(FitToPage(1, 0)) // 1 page wide, unlimited tall\n        PrintArea = [ (CellRef.ofA1 \"A1\", CellRef.ofA1 \"D10\") ]\n        Header = Some \"&C&\\\"Arial,Bold\\\"Quarterly Report\"\n        FirstHeader = Some \"&CCover Page\" // shown only on page 1\n        EvenFooter = Some \"&L&F\" } ] // shown only on even pages\n```\n\nSee [MAPPING.md](MAPPING.md) for what isn't modeled (totals-row/headerless tables, and a\nhandful of minor `pageSetup` attributes like print page order).\n\nTables are also a `SheetItem` - `Table` (the DU case) takes a plain `TableEntry` record\n(the type), no smart constructor, same as `Protect`/`PageSetup`. Core doesn't synthesize\nthe header row's cell text for you, so it must already be there as ordinary cells - the\nsame way conditional formatting/autofilter/merges only describe metadata layered on top of\ncells you've already placed:\n\n```fsharp\nsheet\n    \"Sheet1\"\n    [ row [ cell (Text \"Item\"); cell (Text \"Quantity\") ]\n      row [ cell (Text \"Widgets\"); cell (Number 12.0) ]\n      Table\n          { TopLeft = CellRef.ofA1 \"A1\"\n            BottomRight = CellRef.ofA1 \"B2\"\n            Name = \"Inventory\"\n            Columns = [ { Name = \"Item\"; CalculatedFormula = None }; { Name = \"Quantity\"; CalculatedFormula = None } ]\n            Style = TableStyle.Default } ]\n```\n\nStructured references (`Table1[Column]`) need no special handling - they're just raw\nformula text in a `Formula` cell, same as any other formula. See [MAPPING.md](MAPPING.md)\nfor what isn't modeled (totals row, headerless tables).\n\nSparklines follow the same shape - `SparklineGroup` (the DU case) takes a plain\n`SparklineGroupEntry` record:\n\n```fsharp\n[ SparklineGroup\n    { Style = { SparklineStyle.Default with Type = Column; ShowNegative = true }\n      Sparklines =\n        [ { Cell = CellRef.ofA1 \"E1\"; DataTopLeft = CellRef.ofA1 \"A1\"; DataBottomRight = CellRef.ofA1 \"D1\" } ] } ]\n```\n\nSparklines are a Microsoft extension (living in the worksheet's `extLst`), not core\nSpreadsheetML - unlike the rest of this library, schema validation alone can't confirm\nreal Excel renders one correctly, so treat this one with a bit more caution and verify in\nreal Excel before relying on it. See [MAPPING.md](MAPPING.md) for what isn't modeled\n(axis settings, per-role colors beyond the main series color).\n\nCharts are the same shape too - `EmbeddedChart` (not bare `Chart`, which collides with\nthe OOXML SDK's own type - see `Builders.fs`) takes a plain `ChartEntry` record. A\nseries' `Name` is a reference to the cell that names it (its column header, typically),\nlive-updating the same way a real Excel chart's series name does - not a static copy:\n\n```fsharp\n[ EmbeddedChart\n    { Type = ChartColumn\n      Title = Some \"Sales by Quarter\"\n      CategoriesTopLeft = CellRef.ofA1 \"A2\"\n      CategoriesBottomRight = CellRef.ofA1 \"A4\"\n      Series = [ { Name = CellRef.ofA1 \"B1\"; ValuesTopLeft = CellRef.ofA1 \"B2\"; ValuesBottomRight = CellRef.ofA1 \"B4\" } ]\n      ShowLegend = true\n      TopLeftAnchor = CellRef.ofA1 \"E1\"\n      BottomRightAnchor = CellRef.ofA1 \"L15\" } ]\n```\n\nUnlike Sparklines, charts are core, fully schema-driven DrawingML/ChartML - built from\ntyped OOXML SDK classes the same way every other feature is, not an extension mechanism.\nSee [MAPPING.md](MAPPING.md) for what isn't modeled (chart kinds beyond column/bar/line/\npie, per-series styling, stacked grouping).\n\nImages are anchored the same way - `EmbeddedImage` takes a plain `ImageEntry` record.\n`Data` is just the image file's own raw bytes (read it with `System.IO.File.ReadAllBytes`,\nfor example) - this DSL doesn't decode or re-encode anything, only embeds and hands back\nexactly what you give it:\n\n```fsharp\n[ EmbeddedImage\n    { Data = System.IO.File.ReadAllBytes(\"logo.png\")\n      Format = Png\n      TopLeftAnchor = CellRef.ofA1 \"A1\"\n      BottomRightAnchor = CellRef.ofA1 \"C6\" } ]\n```\n\nA worksheet's charts and images share one drawing canvas under the hood (Excel only gives\na sheet one at all), which is transparent to you as a caller - just add both kinds of\n`SheetItem` to the same sheet. See [MAPPING.md](MAPPING.md) for what isn't modeled (formats\nbeyond PNG/JPEG/GIF/BMP, free-floating position, cropping, linked-not-embedded images).\n\nPivot tables are also a `SheetItem` - `EmbeddedPivotTable` (not bare `PivotTable`, again\nfor naming consistency with `EmbeddedChart`/`EmbeddedImage`) takes a plain `PivotTableEntry`\nrecord. Unlike every other feature, this one does real work at write time rather than a\npure translation: it groups the source range by `RowField` (and `ColumnField`, if given),\naggregates `ValueField`, and writes both a real Excel pivot cache and the resulting grid of\ncomputed cells:\n\n```fsharp\n[ EmbeddedPivotTable\n    { SourceSheet = None // defaults to this sheet; can name another\n      SourceTopLeft = CellRef.ofA1 \"A1\"\n      SourceBottomRight = CellRef.ofA1 \"C5\"\n      RowField = \"Region\"\n      ColumnField = Some \"Quarter\"\n      ValueField = \"Sales\"\n      Aggregation = PivotSum\n      ValueCaption = Some \"Total Sales\"\n      TopLeftAnchor = CellRef.ofA1 \"E1\" } ]\n```\n\nThe source range's first row must be plain `Text` header cells naming each field. This is\ndeliberately scoped to what a single field per axis can express - one row field, at most\none column field, one value field, Tabular layout, grand totals only - see\n[MAPPING.md](MAPPING.md) for the reasoning and what a richer pivot table (nested fields,\nmultiple value fields, page filters) would need instead.\n\n## Regenerating a file as F# source\n\nGiven a `Workbook` (typically one you just `Workbook.load`ed from an existing file),\n`Workbook.generateScript` renders it back out as a self-contained `.fsx` script that\nrebuilds an equivalent file when run - a code-generating counterpart to `Workbook.load`,\none level further than the reverse transform: instead of data, you get DSL *source text*.\nIt has no opinion on how the script locates the FsOpenXmlDsl assembly, so you supply the\n`#r` lines yourself:\n\n```fsharp\nlet wb = Workbook.load \"input.xlsx\"\n\nlet referenceLines =\n    [ \"#r \\\"path/to/Kookerella.FsOpenXmlDsl.dll\\\"\"\n      \"#r \\\"path/to/DocumentFormat.OpenXml.dll\\\"\" ]\n\nlet script = Workbook.generateScript referenceLines \"output.xlsx\" wb\nSystem.IO.File.WriteAllText(\"regenerate.fsx\", script)\n```\n\nRunning `dotnet fsi regenerate.fsx` produces `output.xlsx` - 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. Generated code only ever mentions\nfields that differ from `CellStyle.Default`/`BorderStyle.None`/etc., and only gives a\nrow/cell an explicit `index`/`col` where the source actually has a gap - see\n`Interpreter/CodeGen.fs`. Every scenario under `tests/Kookerella.FsOpenXmlDsl.Tests/Examples/` has a\ncommitted `script.fsx` generated exactly this way; the `Category=Slow` test group is what\nactually runs each one via `dotnet fsi` and checks it reproduces the committed `.xlsx`.\n\n## XML\n\n`Xml.toWorkbook`/`Xml.ofWorkbook` (in `Xml.fs`) are a third way in and out of the DSL,\nalongside writing F#/C# directly and code generation: plain XML, against a real schema\n(`Xml.xsd`, embedded in the assembly). This exists for a caller who'd rather generate or\nconsume data than write code at all. Two concrete uses:\n\n- **Build an `.xlsx` from XML a transform engine already produces** - an XSLT pipeline (or\n  any templating that emits XML) can target Excel directly, without learning the OOXML\n  schema or this library's own API.\n- **Convert an existing `.xlsx` to XML for version control** - `.xlsx` is a binary ZIP, so\n  `git diff` on one is useless; converting to XML first makes a real, human-readable diff\n  possible. `Xml.ofWorkbook`'s output is deterministically ordered (sorted by cell position,\n  or by name for defined names) regardless of the order the underlying `Workbook`'s lists\n  happen to be in, so a genuine content change produces a small, isolated diff rather than a\n  spurious one from rows/rules getting reshuffled between runs.\n\n```fsharp\nopen System.Xml.Linq\n\n// XML -> Workbook -> .xlsx\nlet wb = XElement.Load \"report.xml\" |> Xml.toWorkbook\nWorkbook.save \"report.xlsx\" wb\n\n// .xlsx -> Workbook -> XML\nlet xml = Workbook.load \"report.xlsx\" |> Xml.ofWorkbook\nxml.Save \"report.xml\"\n```\n\nA discriminated union case becomes an XML element named after the case (camelCased) when\nit carries data of its own, or an attribute *value* (also camelCased) when it's one of\nseveral parameterless alternatives - e.g. a cell's value:\n\n```xml\n<cell ref=\"B2\">\n  <number>42.5</number>\n  <style>\n    <numberFormat kind=\"currency\" />\n  </style>\n</cell>\n```\n\nA richer example - `ValidationKind`'s six cases follow the same convention, and\n`ValidationAlert`'s fields are written as attributes directly on `<dataValidation>` itself\nrather than nested:\n\n```xml\n<dataValidation topLeft=\"A2\" bottomRight=\"A2\" errorTitle=\"Invalid quantity\"\n                errorMessage=\"Quantity must be a positive whole number.\">\n  <wholeNumberValidation operator=\"greaterThan\" formula1=\"0\" />\n</dataValidation>\n```\n\n`ConditionalFormatRule`'s seven cases follow the same convention too, nesting a full\n`CellStyle` where the rule needs one - note `<fill>` holds `<rgb>`/`<indexed>`/`<theme>`\ndirectly, with no extra wrapper element:\n\n```xml\n<conditionalFormat topLeft=\"A1\" bottomRight=\"A3\">\n  <cellValueRule operator=\"greaterThan\" formula1=\"100\">\n    <style>\n      <fill>\n        <rgb r=\"255\" g=\"199\" b=\"206\" />\n      </fill>\n    </style>\n  </cellValueRule>\n</conditionalFormat>\n```\n\nA `Chart`'s `Series` list needs its own wrapper element (`<series>`) distinct from each\nitem's own element name (`<s>`), to avoid a real ambiguity XML has and JSON doesn't - a\nlist has no shape of its own in XML the way a JSON array does, so the container and its\nitems need different names or a reader can't tell where the list starts:\n\n```xml\n<chart type=\"column\" title=\"Sales by Quarter\" showLegend=\"true\"\n       anchorTopLeft=\"E1\" anchorBottomRight=\"L15\">\n  <categories topLeft=\"A2\" bottomRight=\"A4\" />\n  <series>\n    <s name=\"B1\" valuesTopLeft=\"B2\" valuesBottomRight=\"B4\" />\n    <s name=\"C1\" valuesTopLeft=\"C2\" valuesBottomRight=\"C4\" />\n  </series>\n</chart>\n```\n\nAn Excel `Table` shows the more usual case for that same wrapper/item split - `columns`\nalready has a natural singular (`column`), so no `<s>`-style workaround is needed:\n\n```xml\n<table topLeft=\"A1\" bottomRight=\"B4\" name=\"Calc\">\n  <columns>\n    <column name=\"Qty\" />\n    <column name=\"Doubled\" calculatedFormula=\"Calc[Qty]*2\" />\n  </columns>\n  <style name=\"TableStyleLight9\" showFirstColumn=\"true\" showLastColumn=\"true\"\n         showColumnStripes=\"true\" />\n</table>\n```\n\nA `SparklineGroup`'s `Color` field wraps in its own `<color>` child element, same\nconvention `CellStyle`'s font/fill use:\n\n```xml\n<sparklineGroup>\n  <style type=\"column\" lineWeight=\"1.5\" showNegative=\"true\">\n    <color>\n      <rgb r=\"0\" g=\"112\" b=\"192\" />\n    </color>\n  </style>\n  <sparklines>\n    <sparkline cell=\"E1\" dataTopLeft=\"A1\" dataBottomRight=\"D1\" />\n  </sparklines>\n</sparklineGroup>\n```\n\nA `PivotTable` is the flattest shape here - just attributes, no nested elements at all.\nNote this only carries the *description* through: loading one via `Xml.toWorkbook` doesn't\nre-run the aggregation, unlike everything else this schema covers:\n\n```xml\n<pivotTable sourceSheet=\"Data\" sourceTopLeft=\"A1\" sourceBottomRight=\"C9\"\n            rowField=\"Region\" columnField=\"Quarter\" valueField=\"Sales\"\n            aggregation=\"average\" valueCaption=\"Avg Sales\" anchorTopLeft=\"F1\" />\n```\n\nAn `Image`'s raw bytes are the element's own base64 text content, the same convention\n`vbaProject` below uses:\n\n```xml\n<image format=\"gif\" topLeft=\"A1\" bottomRight=\"D6\">R0lGODlhAQABAIAAAAAAAP...</image>\n```\n\nA `Hyperlink`'s `Target` nests the same way `ValidationKind`/`ConditionalFormatRule` do:\n\n```xml\n<hyperlink topLeft=\"A1\" bottomRight=\"A1\" tooltip=\"Visit site\">\n  <externalHyperlink>https://example.com</externalHyperlink>\n</hyperlink>\n<hyperlink topLeft=\"A2\" bottomRight=\"B3\" display=\"Go to top\">\n  <internalHyperlink>Sheet1!A1</internalHyperlink>\n</hyperlink>\n```\n\nA `Comment`'s text is also the element's own content, not an attribute - `author` is\nsimply omitted when empty rather than written as `author=\"\"`:\n\n```xml\n<comment cell=\"A1\" author=\"Alex\">Check this figure</comment>\n<comment cell=\"A2\">Unnamed author</comment>\n```\n\nSheet and workbook protection are both flat attribute bags - no nested elements needed,\nsince none of `SheetProtection`/`WorkbookProtection`'s fields are structured data:\n\n```xml\n<protection password=\"hunter2\" sheet=\"true\" formatCells=\"true\" sort=\"true\" autoFilter=\"true\" />\n```\n\n```xml\n<workbook>\n  <sheets>...</sheets>\n  <protection password=\"hunter2\" lockStructure=\"true\" />\n</workbook>\n```\n\n`PageSetup` shows the mixed-DU convention again - `PaperSize`'s named cases become a\n`kind` attribute, the same escape-hatch shape `NumberFormat` uses on a cell's style:\n\n```xml\n<pageSetup orientation=\"landscape\">\n  <paperSize kind=\"a4\" />\n  <margins left=\"0.5\" right=\"0.5\" top=\"1\" bottom=\"1\" header=\"0.2\" footer=\"0.2\" />\n</pageSetup>\n```\n\n`PrintScaling`'s two cases, `PaperSize`'s escape hatch (`other`, for any of the several\ndozen paper codes not worth naming), `PrintArea`'s list of ranges, and header/footer text\nall together:\n\n```xml\n<pageSetup orientation=\"portrait\">\n  <paperSize other=\"9\" />\n  <scaling fitWidth=\"1\" fitHeight=\"0\" />\n  <margins left=\"0.7\" right=\"0.7\" top=\"0.75\" bottom=\"0.75\" header=\"0.3\" footer=\"0.3\" />\n  <printArea>\n    <range topLeft=\"A1\" bottomRight=\"D10\" />\n  </printArea>\n  <header>&amp;C&amp;\"Arial,Bold\"Report</header>\n  <footer>&amp;LPage &amp;P of &amp;N</footer>\n</pageSetup>\n```\n\nA macro-enabled workbook's `VbaProject` bytes sit at the workbook level, alongside\n`sheets`, not inside any one sheet:\n\n```xml\n<workbook>\n  <sheets>...</sheets>\n  <vbaProject>AQIDBA==</vbaProject>\n</workbook>\n```\n\n`DefinedNameScope`'s two cases show a different shape than `PaperSize`/`NumberFormat`'s\n\"kind attribute\" trick: `WorkbookScope` carries no data of its own, yet still becomes its\nown (empty) element rather than an attribute value, since it sits in a `<choice>` alongside\n`SheetScope`, which does carry data:\n\n```xml\n<definedNames>\n  <definedName name=\"LocalTotal\" formula=\"Sheet1!$A$2\" hidden=\"true\">\n    <sheetScope sheetName=\"Sheet1\" />\n  </definedName>\n  <definedName name=\"TaxRate\" formula=\"0.075\">\n    <workbookScope />\n  </definedName>\n</definedNames>\n```\n\nThe smaller range-shaped fields (`MergedRange`, `FreezePane`, `AutoFilter`, `ColumnProps`,\n`RowProps`) are all straightforward attribute bags or lists of them:\n\n```xml\n<mergedRanges>\n  <mergedRange topLeft=\"A1\" bottomRight=\"C1\" />\n</mergedRanges>\n<freezePane rows=\"1\" columns=\"0\" />\n<autoFilter topLeft=\"A1\" bottomRight=\"D11\" />\n<columnProps>\n  <columnProp index=\"0\" width=\"20\" />\n</columnProps>\n<rowProps>\n  <rowProp index=\"0\" height=\"30\" />\n</rowProps>\n```\n\n`Xml.schemaSet()` loads the compiled schema for validating either direction yourself\n(`XDocument.Validate`) - every scenario under `tests/Kookerella.FsOpenXmlDsl.Tests/Examples/`\nhas a committed `workbook.xml` validated against it this way as part of the same test that\ngenerates it, so the schema and `Xml.fs` itself can never silently drift apart. `Xml.fs`\ncovers the same worksheet/workbook-level feature set as the rest of this library and the C#\nwrapper - cell values, styles, merged ranges, freeze panes, autofilter, column/row sizing,\nVBA (base64), defined names, hyperlinks, comments, sheet/workbook protection, print\nsettings, images (base64), Excel Tables, sparklines, charts, pivot tables (the description\nonly - loading one doesn't re-run its aggregation, unlike everything else here), conditional\nformatting, and data validation.\n\n`Kookerella.FsOpenXmlDsl.Mcp` exposes both directions without writing any F# at all:\n`generate_xml`/`create_workbook_from_xml` MCP tools for an AI agent, and `fsopenxmldsl-mcp\nconvert --lang xml`/`build` CLI commands for anyone else - see that project's own README.\n\n## JSON\n\n`Json.toWorkbook`/`Json.ofWorkbook` (in `Json.fs`) are a fourth way in and out of the DSL,\nalongside writing F#/C# directly, code generation, and XML: plain JSON, for a caller whose\ntooling speaks JSON rather than XML. The same two concrete uses XML has apply here:\n\n- **Build an `.xlsx` from JSON a transform/generation pipeline already produces** - without\n  learning the OOXML schema or this library's own API.\n- **Convert an existing `.xlsx` to JSON for version control** - the same determinism\n  `Xml.ofWorkbook` has (sorted by cell position, or by name for defined names) applies to\n  `Json.ofWorkbook`'s output too, for the same reason: a genuine content change produces a\n  small, isolated diff rather than a spurious one from lists getting reshuffled between runs.\n\n```fsharp\nopen System.Text.Json.Nodes\n\n// JSON -> Workbook -> .xlsx\nlet wb = JsonNode.Parse(File.ReadAllText \"report.json\").AsObject() |> Json.toWorkbook\nWorkbook.save \"report.xlsx\" wb\n\n// .xlsx -> Workbook -> JSON\nlet json = Workbook.load \"report.xlsx\" |> Json.ofWorkbook\nFile.WriteAllText(\"report.json\", json.ToJsonString())\n```\n\nA discriminated union case becomes a single-key JSON object named after the case\n(camelCased) when it carries data of its own, or a bare JSON string (also camelCased) when\nit's one of several parameterless alternatives - e.g. a cell's value:\n\n```json\n{\n  \"ref\": \"B2\",\n  \"number\": 42.5,\n  \"style\": { \"numberFormat\": \"currency\" }\n}\n```\n\nThe same `DataValidation` example as above, in JSON - unlike the XML surface, which\nflattens `ValidationAlert`'s fields onto `<dataValidation>` itself, JSON nests both `kind`\nand `alert` as their own objects, the more natural shape for this format:\n\n```json\n{\n  \"topLeft\": \"A2\",\n  \"bottomRight\": \"A2\",\n  \"kind\": { \"wholeNumberValidation\": { \"operator\": \"greaterThan\", \"formula1\": \"0\" } },\n  \"alert\": {\n    \"errorTitle\": \"Invalid quantity\",\n    \"errorMessage\": \"Quantity must be a positive whole number.\"\n  }\n}\n```\n\nThe same `ConditionalFormat` example as above, in JSON - `rule` nests one of the seven\ncases the same way `kind` does above, and (unlike XML's bare `<fill>`) `fill` always wraps\nits `color` under an explicit key:\n\n```json\n{\n  \"topLeft\": \"A1\",\n  \"bottomRight\": \"A3\",\n  \"rule\": {\n    \"cellValueRule\": {\n      \"operator\": \"greaterThan\",\n      \"formula1\": \"100\",\n      \"style\": { \"fill\": { \"color\": { \"rgb\": { \"r\": 255, \"g\": 199, \"b\": 206 } } } }\n    }\n  }\n}\n```\n\nThe same `Chart` example as above, in JSON - `series` is a plain array, with no need for\nthe wrapper-vs-item-name trick `<series>`/`<s>` exist for in XML, since a JSON array is\nself-delimiting:\n\n```json\n{\n  \"type\": \"column\",\n  \"title\": \"Sales by Quarter\",\n  \"showLegend\": true,\n  \"anchorTopLeft\": \"E1\",\n  \"anchorBottomRight\": \"L15\",\n  \"categories\": { \"topLeft\": \"A2\", \"bottomRight\": \"A4\" },\n  \"series\": [\n    { \"name\": \"B1\", \"valuesTopLeft\": \"B2\", \"valuesBottomRight\": \"B4\" },\n    { \"name\": \"C1\", \"valuesTopLeft\": \"C2\", \"valuesBottomRight\": \"C4\" }\n  ]\n}\n```\n\nThe same `Table` example as above, in JSON - `columns` is just another plain array, same\nas `series`:\n\n```json\n{\n  \"topLeft\": \"A1\",\n  \"bottomRight\": \"B4\",\n  \"name\": \"Calc\",\n  \"columns\": [\n    { \"name\": \"Qty\" },\n    { \"name\": \"Doubled\", \"calculatedFormula\": \"Calc[Qty]*2\" }\n  ],\n  \"style\": {\n    \"name\": \"TableStyleLight9\",\n    \"showFirstColumn\": true,\n    \"showLastColumn\": true,\n    \"showColumnStripes\": true\n  }\n}\n```\n\nThe same `SparklineGroup` example as above, in JSON - `color` sits as a plain nested key\nalongside the style's other fields, the same way `fill`'s does under `CellStyle`:\n\n```json\n{\n  \"style\": {\n    \"type\": \"column\",\n    \"lineWeight\": 1.5,\n    \"showNegative\": true,\n    \"color\": { \"rgb\": { \"r\": 0, \"g\": 112, \"b\": 192 } }\n  },\n  \"sparklines\": [\n    { \"cell\": \"E1\", \"dataTopLeft\": \"A1\", \"dataBottomRight\": \"D1\" }\n  ]\n}\n```\n\nThe same `PivotTable` example as above, in JSON - a flat object either way, since there's\nnothing here that's a list or a nested structure:\n\n```json\n{\n  \"sourceSheet\": \"Data\",\n  \"sourceTopLeft\": \"A1\",\n  \"sourceBottomRight\": \"C9\",\n  \"rowField\": \"Region\",\n  \"columnField\": \"Quarter\",\n  \"valueField\": \"Sales\",\n  \"aggregation\": \"average\",\n  \"valueCaption\": \"Avg Sales\",\n  \"anchorTopLeft\": \"F1\"\n}\n```\n\nAn `Image`'s bytes are a base64 string value, same as `vbaProject` below:\n\n```json\n{ \"format\": \"gif\", \"topLeft\": \"A1\", \"bottomRight\": \"D6\", \"data\": \"R0lGODlhAQABAIAAAAAAAP...\" }\n```\n\nA `Hyperlink`, in JSON:\n\n```json\n{\n  \"topLeft\": \"A1\",\n  \"bottomRight\": \"A1\",\n  \"target\": { \"externalHyperlink\": \"https://example.com\" },\n  \"tooltip\": \"Visit site\"\n}\n```\n```json\n{\n  \"topLeft\": \"A2\",\n  \"bottomRight\": \"B3\",\n  \"target\": { \"internalHyperlink\": \"Sheet1!A1\" },\n  \"display\": \"Go to top\"\n}\n```\n\nA `Comment` - `author` is a plain optional field, omitted rather than an empty string:\n\n```json\n{ \"cell\": \"A1\", \"author\": \"Alex\", \"text\": \"Check this figure\" }\n{ \"cell\": \"A2\", \"text\": \"Unnamed author\" }\n```\n\nSheet and workbook protection, in JSON - flat objects, same as the XML:\n\n```json\n{ \"password\": \"hunter2\", \"sheet\": true, \"formatCells\": true, \"sort\": true, \"autoFilter\": true }\n```\n\n```json\n{ \"sheets\": [ { \"name\": \"Sheet1\" } ], \"protection\": { \"password\": \"hunter2\", \"lockStructure\": true } }\n```\n\n`PageSetup` - `PaperSize`'s named cases are a bare string, the mixed-DU convention\n`NumberFormat` also uses:\n\n```json\n{\n  \"orientation\": \"landscape\",\n  \"paperSize\": \"a4\",\n  \"margins\": { \"left\": 0.5, \"right\": 0.5, \"top\": 1, \"bottom\": 1, \"header\": 0.2, \"footer\": 0.2 }\n}\n```\n\nThe same richer example as above, in JSON - `PaperSize`'s escape hatch is `{\"other\": 9}`,\n`PrintScaling`'s two cases are single-key objects same as everywhere else, and `printArea`\nis a plain array:\n\n```json\n{\n  \"orientation\": \"portrait\",\n  \"paperSize\": { \"other\": 9 },\n  \"scaling\": { \"fitToPage\": { \"width\": 1, \"height\": 0 } },\n  \"margins\": { \"left\": 0.7, \"right\": 0.7, \"top\": 0.75, \"bottom\": 0.75, \"header\": 0.3, \"footer\": 0.3 },\n  \"printArea\": [ { \"topLeft\": \"A1\", \"bottomRight\": \"D10\" } ],\n  \"header\": \"&C&\\\"Arial,Bold\\\"Report\",\n  \"footer\": \"&LPage &P of &N\"\n}\n```\n\n**Worth knowing**: `System.Text.Json`'s default encoder escapes every `&`, `<`, `>`, and `\"`\ncharacter inside a string value as a `\\uXXXX` sequence (the conservative choice for JSON\nthat might end up embedded in HTML), so `generate_json`'s *actual* output for the header\nabove has each of those characters replaced that way, not left as plain text the way it's\nshown here for readability. This is cosmetic, not a data-loss bug (`Json.toWorkbook` parses\nthe escaped form back to the exact original string either way, verified by round-tripping\nthis exact example) - but it's worth knowing before assuming a `generate_json` result is\ncorrupted, especially since Excel's header/footer codes (`&C`/`&L`/`&R`/`&P`/`&N`/...) all\nbegin with `&`, so real header/footer text is guaranteed to render this way.\n\n`VbaProject`, at the workbook level:\n\n```json\n{ \"sheets\": [ { \"name\": \"Sheet1\" } ], \"vbaProject\": \"AQIDBA==\" }\n```\n\n`DefinedNameScope`'s two cases follow the standard JSON convention cleanly, unlike XML's\n`<workbookScope />`/`<sheetScope>` split - `WorkbookScope` is simply the bare string, same\ntreatment as any other parameterless case:\n\n```json\n{\n  \"definedNames\": [\n    { \"name\": \"LocalTotal\", \"formula\": \"Sheet1!$A$2\", \"scope\": { \"sheetScope\": \"Sheet1\" }, \"hidden\": true },\n    { \"name\": \"TaxRate\", \"formula\": \"0.075\", \"scope\": \"workbookScope\" }\n  ]\n}\n```\n\nThe smaller range-shaped fields, in JSON:\n\n```json\n{\n  \"mergedRanges\": [ { \"topLeft\": \"A1\", \"bottomRight\": \"C1\" } ],\n  \"freezePane\": { \"rows\": 1, \"columns\": 0 },\n  \"autoFilter\": { \"topLeft\": \"A1\", \"bottomRight\": \"D11\" },\n  \"columnProps\": [ { \"index\": 0, \"width\": 20 } ],\n  \"rowProps\": [ { \"index\": 0, \"height\": 30 } ]\n}\n```\n\nUnlike XML, .NET has no built-in JSON Schema validator the way `System.Xml.Schema` exists\nfor XML, so `Json.schema.json` (in the repo, matching this shape) is validated only from\nthis repo's own test suite (via a test-only `JsonSchema.Net` dependency) rather than exposed\nas a public `Json.schemaSet()`-style API. Every scenario under\n`tests/Kookerella.FsOpenXmlDsl.Tests/Examples/` has a committed `workbook.json` validated\nagainst it this way too, the same as `workbook.xml` is against `Xml.xsd`, so the schema and\n`Json.fs` itself can never silently drift apart there either. `Json.fs` covers the same\nworksheet/workbook-level feature set `Xml.fs` does - cell values, styles, merged ranges,\nfreeze panes, autofilter, column/row sizing, VBA (base64), defined names, hyperlinks,\ncomments, sheet/workbook protection, print settings, images (base64), Excel Tables,\nsparklines, charts, pivot tables (the description only - loading one doesn't re-run its\naggregation, unlike everything else here), conditional formatting, and data validation.\n\n`Kookerella.FsOpenXmlDsl.Mcp` exposes both directions without writing any F# at all:\n`generate_json`/`create_workbook_from_json` MCP tools for an AI agent, and `fsopenxmldsl-mcp\nconvert --lang json`/`build` CLI commands for anyone else - see that project's own README.\n\n## Building and testing\n\n```bash\ndotnet build\ndotnet test --filter \"Category!=Slow\"\ndotnet run --project samples/Kookerella.FsOpenXmlDsl.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\neach, so ~30-60s total) rather than just checking the generated source parses. Run those\nexplicitly, 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\n## Sponsorship\n\nIf this project is useful to you, [sponsoring it](https://github.com/sponsors/Kookerella-Ltd)\nhelps fund ongoing development. Sponsorship supports the project - it doesn't include support\nSLAs, feature guarantees, or priority response times. The software is provided as-is under the\n[MIT license](LICENSE), with or without sponsorship.\n",
  "bytes": 42354,
  "sha": "4e3aae0abbe92419dc5ffd768a103c05f429908b1e9f985b8398ac2fed8228d8",
  "repo_slug": "kookerella-ltd/kookerella.fsopenxmldsl",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_marknicholls_fsopenxmldsl_mcp_d5c1fba2/readme"
}