Markdown Projection
August 15, 2026 · View on GitHub
Status: Implemented. All eight phases of the implementation plan landed; tests under
Docxodus.Tests/WmlToMarkdownConverterTests.cscover the surface and the WASM/npm bridge shipsconvertWmlToMarkdown. The "Implementation Plan (Phases)" section below documents the staging order that produced today's code.
The markdown projection is a deterministic, anchor-addressed rendering of a DOCX as Markdown. It is a sibling to WmlToHtmlConverter and OpenContractExporter in the converter family, intended as a substrate for tooling that wants to operate on Word documents the way it would operate on source files — search, splice, diff, address by ID. Use cases include LLM-driven editing pipelines, structured search indexers, and diff/review UIs that need a text view richer than WmlToHtmlConverter strips down to.

The same DOCX, twice. On the left, WmlToMarkdownConverter.Convert output — every block prefixed
with its anchor. On the right, WmlToHtmlConverter output of the same bytes with StampAnchors
on, so the rendered DOM node carries that same id in data-anchor. The highlighted pair is one
paragraph, addressable identically from a text pipeline and from a DOM.
Goals
- Stable addressing. Every paragraph, heading, list item, table, table cell, comment, footnote, and endnote in the projection is reachable by an anchor that survives reformatting and reordering.
- Deterministic output. Two runs on the same input produce byte-identical output.
- Round-trippable references. An anchor in the projection maps unambiguously back to an
XElement(or set of elements) in the underlying OOXML. The projection itself is read-only — mutation lives in callers — but the anchor → element resolver is part of this module's API. - Lossy by design, honestly. Anything that can't fit in Markdown becomes either a structured opaque anchor (with metadata callers can fetch via the SDK) or a clearly-marked annotation. Silent loss is a bug.
Non-Goals
- Round-trip rendering (Markdown → DOCX). That problem already has
HtmlToWmlConverterandDocumentAssembler; this converter is one-way. - GFM-perfect tables. Word tables (merged cells, nested tables, cell-level shading, vertical text, …) exceed GFM. We render what fits and surface the rest as opaque anchors.
- Preserving every formatting nuance. Bold/italic/code/links/headings/lists/quotes carry over. Font sizes, colors, character spacing, etc. do not — they're recoverable by anchor lookup if needed.
Anchor Scheme
Anchors derive from the Unid system Docxodus already maintains on paragraphs and runs (see AssignUnidToAllElements / the legacy migration notes in CLAUDE.md). They are stable across edits unless the underlying element is removed.
Deterministic across sessions. The projector uses a content-addressable Unid derivation (UnidHelper.AssignToAllElementsDeterministic): a Unid is a SHA-256 hash of parent_unid : tag : content_signature : duplicate_index, truncated to 32 hex chars. Properties:
- Two opens of the same bytes produce identical Unids on every element — anchor ids captured in one
DocxSessionresolve in any fresh session over the same bytes (no need forPersistAnchorIds = true). - Editing a paragraph's text changes only that paragraph's Unid; siblings stay stable.
- Inserting a unique-content paragraph anywhere does not shift any other Unid.
- Inserting / editing a duplicate-content paragraph between duplicates shifts the
duplicate_indexof later duplicates of the same content. Rare in practice.
WmlComparer continues to use a random-Guid Unid path (UnidHelper.AssignToAllElements) — its matching heuristics expect content-independent Unids within each version it compares. Don't change WmlComparer's call site to the deterministic path; the comparer's accuracy regresses if you do (see the class-level remarks on UnidHelper).
Format: {#kind:scope:unid} where:
| Field | Values | Meaning |
|---|---|---|
kind | Assigned: p, h, li, tbl, tr, tc, col, cmt, fn, en, sdt, sec, unk. Reserved (parse, but never assigned to any element): img, drw | Element type |
scope | body, hdr1…hdrN, ftr1…ftrN, fn, en, cmt | Which part of the package |
unid | 8–16 hex chars | Stable element identifier |
Examples:
{#h:body:a1b2c3d4}— heading in the main body{#p:hdr1:9f8e7d6c}— paragraph in the first header part{#tc:body:1a2b3c4d}— table cell in the body{#cmt:cmt:00ff11ee}— comment in the comments part
Anchors appear at the start of the line they refer to (block-level) or as inline {#…} markers (inline annotations like comments anchored to a span).
Why prefix-with-#?
{#…} is the Pandoc / kramdown attribute syntax. Most Markdown renderers either honor it (turning anchors into id attributes) or display it literally without breaking layout. Either way it survives copy/paste round trips through agent contexts.
Element Coverage
| OOXML element | Markdown representation | Notes |
|---|---|---|
w:p (no style) | Paragraph | Anchor on its own line above the text |
w:p styled Heading{1..9} | #…######### heading | Heading level taken from style. ATX headings 7-9 exceed CommonMark; strict renderers degrade to literal text, but downstream parsers and LLMs recover the outline depth (silently clamping to ###### would lose it, which matters for legal/clause-numbered documents). |
w:p styled Heading* + w:numPr | # heading with resolved number prefix | Auto-numbered headings (legal FIRST: … / 1.1 … clause numbering) keep their resolved number; without this, headings render with only the trailing text. |
w:p styled Title / Subtitle | # heading with class | |
w:p with w:numPr | - or 1. list item | Numbering resolved to literal markers; nested via indentation |
w:p styled Quote / IntenseQuote | > blockquote | |
w:p styled Code / HTML Code | Indented or fenced code block | |
w:r with w:b | **bold** | |
w:r with w:i | *italic* | |
w:r with w:rStyle="Code" or monospace | `code` | |
w:r with w:strike | ~~strike~~ | GFM extension |
w:hyperlink | [text](url) | Internal links use anchor: [text](#anchor) |
w:tbl (simple) | GFM pipe table | When no merged cells, nesting, or per-cell formatting |
w:tbl (complex) | Opaque anchor block | {#tbl:body:…} followed by a fenced text block with a structural summary |
w:commentRangeStart…End | Inline {#cmt:cmt:…} markers wrapping the commented span | Comment text appears in a Comments section at end |
w:footnoteReference | [^fn-xxxx] GFM footnote ref | Definitions collected at end |
w:endnoteReference | [^en-xxxx] | Same |
w:drawing / w:pict (image) | Nothing — the Markdown oracle emits no image syntax and assigns no anchor | Images are not projected and there is no img/drw anchor kind. Address them through the native image surface instead (ListImages/imageId, docxodus_images — issue #453), which carries dimensions, alt text and floating layout that Markdown could not express anyway |
w:sdt (content control) | Anchor on the outer SDT; the current Markdown oracle omits inline/block SDT-delivered content | The SDT remains addressable without changing historical Markdown bytes; HTML and ListBlocks flatten/render its content |
w:ins / w:del (tracked changes) | Configurable: accept, show as {+ins+}/{-del-}, or omit | Mirrors WmlToHtmlConverter.RenderTrackedChanges |
w:sectPr | --- thematic break preceded by {#sec:scope:unid} | Section breaks are addressable as sec kind — useful for "find the next section break" tooling. Today no mutation op accepts a sec anchor (only block-level p/h/li/tbl kinds are mutable); treat sec as a passive read-side marker. |
Anything not in the table above renders as a single line:
{#unk:body:…} [unsupported: w:smartTag]
Multipart Namespacing
A DOCX has many "documents" — the body is one part, but headers, footers, footnotes, endnotes, and comments live in sibling parts. The projection emits them as named sections so a single Markdown stream covers the whole package:
# Document
{#p:body:…} The Provider shall...
---
# Headers
## hdr1
{#p:hdr1:…} CONFIDENTIAL
# Footers
## ftr1
{#p:ftr1:…} Page {PAGE} of {NUMPAGES}
# Footnotes
[^fn-aaaa]: {#fn:fn:aaaa} See Section 4.2 for definitions.
# Comments
- {#cmt:cmt:bbbb} **Alice** (2026-05-23): Should this be capitalized?
Callers that only care about the body can pass Scopes = ProjectionScopes.Body to skip the rest.
A --- thematic break separates adjacent non-empty scope sections. Header/footer scopes whose only content is whitespace are suppressed entirely — DOCX files commonly declare 6+ header/footer parts for first-page/even-page/default variants, and emitting empty ## hdrN titles for the unused variants would pad the projection with noise.
Numbering Resolution
Word list numbers are computed from w:numPr referencing numbering.xml (w:abstractNum / w:lvlText), not stored as text. The projection resolves numbering so the agent sees what a human reads:
{#li:body:…} 1. First item
{#li:body:…} 2. Second item
{#li:body:…} a. Nested item
{#li:body:…} 3. Third item
The original numPr is recoverable through the anchor — callers that want to edit the list's numbering format address the source, not the rendered number.
Legal numbering (1.1, 1.1.1, …) and other multi-level formats render verbatim.
Tables
GFM pipe tables when:
- No merged cells (
w:gridSpan,w:vMerge) - No nested tables
- No per-cell formatting beyond bold/italic in cell content
- ≤ ~80 chars per cell (configurable)
Otherwise, an opaque anchor block:
{#tbl:body:t1}
```table
rows: 4
cols: 3
caption: Fee Schedule
notes: merged cells in row 1; nested table in (3,2)
```
Per-cell content is reachable via {#tc:body:…} anchors that the caller can fetch individually with the SDK. The opaque block keeps the projection readable; the anchors keep it addressable.
Round-Trip: Anchor → XElement
The companion API on the converter:
var projection = WmlToMarkdownConverter.Convert(wmlDoc, settings);
// projection.Markdown is the text
// projection.AnchorIndex is an IReadOnlyDictionary<string, AnchorTarget>
var target = projection.AnchorIndex["p:body:a1b2c3d4"];
// target.PartUri, target.ElementXPath, target.Unid
// target.Resolve(WordprocessingDocument) -> XElement
This is the contract that makes the projection useful for editing: callers receive the projection and a way to walk back to the source for any anchor.
The AnchorTarget also exposes a TextPreview field — the first ~80 characters
of the element's flat text — computed during projection. Agents iterating
AnchorIndex for a UI list or LLM context window can read previews directly
without re-walking each element via session.GetAnchorInfo.
For paragraphs / headings / list items in the body that carry numbering
(inline w:numPr or numbering inherited from a style), AnchorTarget also
exposes AutoNumberPrefix — the resolved label Word renders before the
element (e.g. "1.", "1.1", "First"). The prefix is not part of the
flat run text, so it doesn't appear in TextPreview and isn't searchable via
Grep. The convenience AnchorTarget.FullText joins AutoNumberPrefix and
TextPreview for callers that want "what does a reader see?" without
re-resolving numbering. AnchorInfo carries the same two fields.
Word-reserved footnote/endnote separators (type="separator" /
type="continuationSeparator") are excluded from AnchorIndex — they're
structural plumbing for Word's separator-line rendering, have no editorial
content, and cannot be deleted. They do not appear in the projection text either.
Anchor id rendering modes
WmlToMarkdownConverterSettings.AnchorIdRendering controls how anchor ids
appear in the rendered markdown. The underlying anchor identity (the Unid
attribute on the XML element, exposed as AnchorTarget.Unid and as the unid
portion of Anchor.Id) is unchanged — only the display of the id changes.
This is purely a token-budget optimization for LLM-facing pipelines; the
canonical lookup key remains the full Unid.
FullUnid (default)
Anchor tokens use the full 32-hex-char Unid:
{#p:body:a1b2c3d4e5f60718293a4b5c6d7e8f90}
First paragraph text.
{#p:body:c0a5e891b234567890abcdef01234567}
Second paragraph text.
Stable across reorderings and edits. Best when anchor ids cross process boundaries or get persisted (caches, audit logs, undo histories).
Abbreviated
Unids are shortened to the shortest unique prefix per (kind, scope) bucket,
with a 4-char floor:
{#p:body:a1b2}
First paragraph text.
{#p:body:c0a5}
Second paragraph text.
LLM-friendly: tokens shrink from 32 hex chars to ~4-5, freeing context budget in long documents. Saves ~5-10% of the projection's total token count on a typical contract. Within a single projection these ids are unambiguous (the trim algorithm guarantees uniqueness per bucket); across projections they're not stable, so don't persist them.
Sequential
Unids are replaced with 1-based per-bucket counters in document order:
{#p:body:1}
First paragraph text.
{#p:body:2}
Second paragraph text.
{#h:body:1}
A Heading.
Maximally token-efficient. Best for one-shot LLM contexts and replay logs
where stability across edits doesn't matter and the numbering itself carries
useful ordering information. These ids are not stable across Project()
calls — a single insert anywhere in the document can renumber everything
below it — so they must not be persisted or held across mutations.
Dual-keyed AnchorIndex
In non-FullUnid modes, the AnchorIndex is dual-keyed: both the full
Unid AND the rendered (abbreviated / sequential) id resolve to the same
AnchorTarget entry. A caller that reads an abbreviated or sequential id out
of the markdown can hand it straight back to DocxSession.ProjectAnchor (or
any other anchor-addressed method like ReplaceText, DeleteBlock,
ApplyFormat, …) without an explicit translation step. Internally,
DocxSession.FindAnchor first tries the exact key, then falls back to a
Unid-only scan — so even if the prefix metadata has shifted (e.g., a kind
flip from p → h after SetParagraphStyle), the rendered id still
resolves.
Anchor.Token always returns the canonical full-Unid form regardless of the
rendering mode in use — it's the authoritative identifier. The rendered id is
purely a display optimization in the markdown text; downstream code that
wants a stable cross-session handle should read from AnchorTarget.Unid or
Anchor.Token.
Worked example
Given a body with two paragraphs and one heading, the projection under each mode (same document, same Unids underneath):
| Mode | Rendered token (first paragraph) |
|---|---|
FullUnid | {#p:body:a1b2c3d4e5f60718293a4b5c6d7e8f90} |
Abbreviated | {#p:body:a1b2} |
Sequential | {#p:body:1} |
All three of projection.AnchorIndex["p:body:a1b2c3d4e5f60718293a4b5c6d7e8f90"],
projection.AnchorIndex["p:body:a1b2"], and (under Sequential)
projection.AnchorIndex["p:body:1"] return the same AnchorTarget whose
Unid is a1b2c3d4e5f60718293a4b5c6d7e8f90 and whose Anchor.Token is
{#p:body:a1b2c3d4e5f60718293a4b5c6d7e8f90}.
Settings (Planned)
public class WmlToMarkdownConverterSettings
{
// What parts of the package to include.
public ProjectionScopes Scopes = ProjectionScopes.All;
// Heading level offset (e.g., 1 means Word Heading1 -> Markdown ##).
public int HeadingLevelOffset = 0;
// Inline anchors? Block-level only? Or omit?
public AnchorRenderMode AnchorMode = AnchorRenderMode.Block;
// How to handle complex tables.
public TableRenderMode TableMode = TableRenderMode.GfmWithOpaqueFallback;
// Max characters before a simple table becomes opaque.
public int TableInlineCellMax = 80;
// Tracked changes: accept silently, render as {+/-}, or strip dels.
public TrackedChangeMode TrackedChanges = TrackedChangeMode.Accept;
// Resolve list numbering to literal markers (default true).
public bool ResolveNumbering = true;
// Declared but INERT: the converter has no w:drawing/w:pict branch, so this
// callback is threaded through settings and never invoked. It documents an
// intended image-URI scheme, not a behavior. See the Images open question.
public Func<ImageInfo, string>? ImageUriBuilder;
}
[Flags]
public enum ProjectionScopes
{
Body = 1, Headers = 2, Footers = 4, Footnotes = 8, Endnotes = 16, Comments = 32,
All = Body | Headers | Footers | Footnotes | Endnotes | Comments
}
public enum AnchorRenderMode { Block, BlockAndInline, None }
public enum TableRenderMode { GfmWithOpaqueFallback, AlwaysGfm, AlwaysOpaque }
public enum TrackedChangeMode { Accept, RenderInline, StripDeletions }
Implementation Plan (Phases)
- Anchor index. Walk the document, assign/reuse Unids on every block-level element across all parts, build the
AnchorIndex. No markdown output yet — just verify uniqueness and round-trip resolution. - Plain paragraphs + headings. Emit body paragraphs and styled headings with anchors. Tests against
TestFiles/HC*documents. - Inline runs. Bold, italic, code, strike, hyperlinks.
- Lists with resolved numbering. Lean on existing
ListItemRetrieverSettingsinfrastructure. - Simple tables (GFM) + opaque fallback.
- Multipart parts. Headers, footers, footnotes, endnotes, comments.
- Tracked changes rendering modes.
- WASM + npm wrapper. Add
[JSExport]methods and TypeScript types matching the other converters.
Each phase ships with tests in Docxodus.Tests/WmlToMarkdownConverterTests.cs (test prefix MD###).
Getting Started (For Implementers)
Worked Example
Given a DOCX whose body contains, in order:
- A paragraph styled
Heading1with text "Indemnification" - A paragraph (Normal style) with text "The Provider shall indemnify..."
- A two-item bulleted list with text "First obligation" / "Second obligation"
The default-settings projection should produce:
# Document
{#h:body:a1b2c3d4} # Indemnification
{#p:body:e5f6a7b8} The Provider shall indemnify...
{#li:body:c9d0e1f2} - First obligation
{#li:body:3a4b5c6d} - Second obligation
Notes on the example:
- Anchor token sits on its own line preceding the block, separated by a single space from the markdown that follows.
- The
# Documentheader that opens the body scope is fixed (see Multipart Namespacing); no anchor — it's a scope marker, not addressable content. - Unid values are 32-char hex (shortened above for readability); they come from
Guid.NewGuid().ToString().Replace("-", ""). - A blank line separates blocks. Lists are not separated internally.
Phase 2's first test (MD001_HeadingAndParagraph) should round-trip a fixture matching this shape and assert the markdown is byte-identical to the expected string above (with the actual Unids substituted via a helper that reads them off the source DOCX after assignment).
Phase 1 in Detail
Goal: every block-level element in every part of the package has a stable Unid, and MarkdownProjection.AnchorIndex lets you walk back from any anchor to its XElement.
Reuse, don't reinvent.
- Unid attribute name:
PtOpenXml.Unid(defined inDocxodus/PtOpenXmlUtil.cs). - The existing Unid-assignment logic lives in two places, neither of which is general-purpose:
WmlComparer.AssignUnidToAllElements(XElement)— private toWmlComparer.cs:8655. Walks descendants, adds a Unid where missing. Also handles the specialw:footnote/w:endnotecase where the container itself needs a Unid.WmlToXmlUtil.AssignUnidToBlc— block-level only, on aWmlDocumentorWordprocessingDocument.
- For Phase 1, extract the
WmlComparerhelper into a shared internal utility (e.g.UnidHelper.AssignToAllBlockElements) and call it from bothWmlComparerand the new converter. Don't duplicate.
First deliverable (mergeable on its own):
- Extract the Unid helper.
- Implement
WmlToMarkdownConverter.Convert(...)such that it returns aMarkdownProjectionwithMarkdown = ""and a populatedAnchorIndexfor every block-level element (w:p,w:tbl,w:tr,w:tc) across body, headers, footers, footnotes, endnotes, and comments. - Implement
AnchorTarget.Resolve(WordprocessingDocument)— given the anchor'sPartUriandUnid, walk that part's XML to find the matching element. - Tests:
MD001_AnchorIndexIsExhaustive— everyw:p/w:tblin a fixture is reachable by some anchor.MD002_AnchorsAreStable— projecting twice gives the same anchor ids.MD003_AnchorsResolve— every anchor in the index round-trips throughResolveback to a non-nullXElementwhosePtOpenXml.Unidattribute matches.MD004_AnchorsSurviveRoundTrip— load → project → save (viaOpenXmlMemoryStreamDocument) → re-load → re-project produces the same anchor ids for unchanged elements. This is the doc's open question #1; if it fails, the converter must persist Unids back to the document before returning.
Structural Template to Mirror
The new converter's file organization should follow Docxodus/WmlToHtmlConverter.cs:
- One public static class with
Convertentry points. - Per-element handler methods (
WmlToHtmlConverter.ProcessParagraphat line 4279 is the model — see the dispatch inConvertToHtmlTransformaround line 2493). - Recursive descent driven by element name; each handler returns the rendered fragment (string for markdown; for HTML it returns
object/XElement). - Settings passed through every level — don't capture in fields on the static class.
Don't copy WmlToHtmlConverter's ~6000-line bulk; copy its shape. The markdown converter will be much smaller because it intentionally drops most styling.
WASM / npm Propagation (Phase 8)
When Phase 7 is merged, follow the existing pattern from OpenContractExporter:
- Add
[JSExport]-decorated methods towasm/DocxodusWasm/DocumentConverter.csthat take/return JSON-serializable shapes (you can't returnXElementorIReadOnlyDictionarydirectly — flatten toMarkdownProjectionDto). - Add TypeScript types and a wrapper in
npm/src/types.ts+npm/src/index.ts. - Update the
DocxodusWasmExportsinterface. - Build with
cd npm && npm run build, then add Playwright tests undernpm/tests/.
CLAUDE.md's "Feature Development Workflow" section is the canonical checklist for the cross-layer ripple.
Performance Budget (Targets, Not Hard Constraints)
- Anchor index for a 100-page DOCX: < 200ms cold.
- Full projection (Phase 2+): < 1s for a 100-page DOCX, < 5s for 500 pages.
- Memory: O(document size), no full DOM duplication; reuse the OOXML SDK's
WordprocessingDocumentwalk.
If Phase 1 measurements are >2× these numbers, surface in the PR and discuss before adding more functionality.
Open Questions
- Anchor stability across re-serialization. Today Unids are assigned when needed; we should verify they survive
OpenXmlMemoryStreamDocumentround trips and document the lifecycle. If they don't, the converter must persist them back to the document. - Comment-on-span granularity. Word comments can anchor to a span that crosses runs and paragraphs. Inline
{#cmt:…}markers handle intra-paragraph; cross-paragraph spans probably need a start/end pair. - Images. Still entirely unbuilt on this surface: there is no
w:drawing/w:pictemit path, noimg/drwanchor kind, andImageUriBuilderis a declared-but-never-invoked setting. Since #453 shipped a native image surface with real identity (imageId), the open question is no longer "which URI scheme" but whether Markdown should project images at all — and if so, whether the anchor space orimageIdis the addressing authority. - Bidirectional editing. This converter is one-way, but the eventual
MarkdownToWmlstory (or, more likely, anApplyEdit(anchor, op)API) needs to be designed alongside its callers — not in isolation here.
Related
docx_converter.md—WmlToHtmlConverterinternals (the sibling converter this most resembles)opencontracts_export.md— Other "structured export" precedentincremental_annotation_overlay.md— Anchor-based overlay pattern used byExternalAnnotationProjectortracked_changes.md— How tracked changes inform theTrackedChangeModesetting