ADR 0020: The customUI ribbon is readable through a typed view; authoring stays deferred
Status: Accepted (2026-07-24) · customUI read slice
Context
A macro-enabled workbook can customise the Office ribbon through one or two parts hung off the package root _rels/.rels: customUI/customUI.xml (Office 2007 RibbonX, namespace .../office/2006/01/customui) and customUI/customUI14.xml (Office 2010+, namespace .../office/2009/07/customui, which also adds backstage, QAT and commands). Until now the library handled both only opaquely. Read captured each as a PreservedRootReference (bytes, content type, relationship closure) and the writer re-declared it verbatim in the regenerated root rels, so a ribbon survived a load/edit/save intact but nothing could look inside it through the public API. docs/knowledge/specs/xlsm-macro-preservation.md named "parse the ribbon XML into a model, or leave it opaque?" as a deferred question: round-trip fidelity was solved, but no consumer had forced a reader.
This mirrors exactly the position VBA was in before ADR 0016. Preservation proved the bytes were there; a typed read view was a bounded, self-contained slice that did not need a downstream caller to justify existing, the same way Workbook.vbaProject did not. CLAUDE.md §3 ("bias to action… make the reasonable call") authorises building it now.
The schema was pinned against the Microsoft-published references (MS-CUSTOMUI / MS-CUSTOMUI2 via the Microsoft Learn docs), not guessed. Doing so surfaced a latent wrong fact in the existing fixture (see Consequences).
Decision
The ribbon is exposed as a read-only, typed view, not a new source of truth.
Workbook.customUI: readonly CustomUiDocument[]parses each preserved-rootcustomUIpart lazily and memoises the result. EachCustomUiDocumentcarries itsdialect('2007'or'2010') and the parsedRibbon(tabs → groups → controls). Barrel-exported fromsrc/customuialongsideparseCustomUi,CustomUiParseError, the control/tab/group types, and the namespace and relationship-type constants.Preservation stays the sole emission authority. There is no write path from
CustomUiDocumentback to bytes. Editing a workbook re-emits the originalcustomUIXML unchanged, so the ribbon read view is strictly additive and cannot regress ribbon preservation, because only one representation is ever serialised. Same invariant as the VBA read view (ADR 0016 §2).Dialect is keyed off the XML namespace, not the relationship type. The
<customUI>root namespace is the authoritative signal; the OPC relationship type is only used to discover which root references are ribbon parts (isCustomUiRelType, matching the/ui/extensibilitysuffix that both real rel types share,.../2006/relationships/ui/extensibilityfor 2007 and, confusingly,.../2007/relationships/ui/extensibilityfor 2010). This was a deliberate robustness call: the relationship type is frequently mis-copied (see Consequences), the namespace is not.Scope is the
<ribbon>subtree only. Tabs, groups and controls, plus each control's callback names (onActionis the whole reason a macro workbook ships a ribbon). A document's<commands>,<backstage>,<contextMenus>, and the ribbon'sqatandcontextualTabsare not parsed. They still round-trip byte-for-byte, they are simply not surfaced.CustomUiDocument.ribbonisundefinedfor a document that customises only those. The shape leaves room to addbackstageandqatlater without breaking callers.The control model is a discriminated union keyed by element name, over a shared base.
RibbonControl.kindis the closed set of RibbonX control element local-names, with'unknown'as the fallback so an unrecognised element is surfaced rather than dropped. The common attributes (id/idQ/idMso/label/onAction) are lifted onto the typed view; container controls carrychildren; and the full raw attribute map is preserved on every control, so nothing is lost. Exhaustive per-control attribute typing is deliberately deferred. Pinning the exact schema of ~15 control types for a no-consumer v1 is a lot of API for a lot of risk, and the raw map already covers the manyget*dynamic callbacks and layout hints the typed fields don't. When a consumer needs a specific control's exotic attributes typed, that's an additive refinement.The parser is treated as hostile-input-facing (CLAUDE.md §3). It builds on the entity-safe
xmlEventsscanner (no DTD or entity expansion), caps nesting depth so a deeply-nested part cannot overflow the recursive walk, and fails closed withCustomUiParseErroron malformed XML, an unbalanced tree, a missing<customUI>root, or an unrecognised namespace, never a crash or a half-built tree. A ribbon-free workbook yields an empty array, never an error.Authoring stays out of scope. Reading only. Mutating a ribbon back into valid RibbonX with correct
idQand callback wiring is a separate, larger effort with its own consumer-need question, the same posture ADR 0016 held for VBA authoring in its first slice.
Consequences
- Positive:
workbook.customUI[*].ribbon?.tabs[*].groups[*].controls[*].onActionreads the ribbon and its macro callbacks through a precisely-typed view, with zero new dependencies (it reuses the reader'sxmlEventsSAX scanner and fflate's UTF-8 decode) and no risk to the preservation guarantee. - Corrected on the way in, with a latent wrong fact fixed. The
preserved-parts.test.tsround-trip fixture declared thecustomUI14relationship asType=".../office/2009/07/customui", the schema namespace copy-pasted where the relationship type belongs. The round-trip test passed anyway because preservation is verbatim, faithfully carrying garbage in and garbage out, so the error was invisible until a reader depended on the type. It is corrected to the real.../office/2007/relationships/ui/extensibilityso the fixture represents a genuine Office file, and the reader keys dialect off the namespace to be robust to exactly this class of mistake in the wild. - Negative / deferred: callers can read the ribbon tree but not create or edit it, and backstage, QAT, contextualTabs and commands remain opaque, though they still round-trip. Forward map, each gated on a forcing consumer: parse the customUI14 backstage and QAT surface; type a specific control's full attribute set; and the large one, ribbon authoring (model to valid RibbonX bytes).
- Revisit when: a concrete consumer needs backstage data, a fully-typed control, or ribbon authoring. No new ADR unless the shape of the decision changes.