Markdown Projection

August 15, 2026 · View on GitHub

Status: Implemented. All eight phases of the implementation plan landed; tests under Docxodus.Tests/WmlToMarkdownConverterTests.cs cover the surface and the WASM/npm bridge ships convertWmlToMarkdown. 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.

Markdown projection beside the rendered document

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

  1. 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.
  2. Deterministic output. Two runs on the same input produce byte-identical output.
  3. 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.
  4. 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 HtmlToWmlConverter and DocumentAssembler; 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 DocxSession resolve in any fresh session over the same bytes (no need for PersistAnchorIds = 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_index of 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:

FieldValuesMeaning
kindAssigned: p, h, li, tbl, tr, tc, col, cmt, fn, en, sdt, sec, unk. Reserved (parse, but never assigned to any element): img, drwElement type
scopebody, hdr1hdrN, ftr1ftrN, fn, en, cmtWhich part of the package
unid8–16 hex charsStable 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 elementMarkdown representationNotes
w:p (no style)ParagraphAnchor on its own line above the text
w:p styled Heading{1..9}########## headingHeading 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 prefixAuto-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 itemNumbering resolved to literal markers; nested via indentation
w:p styled Quote / IntenseQuote> blockquote
w:p styled Code / HTML CodeIndented 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 tableWhen 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:commentRangeStartEndInline {#cmt:cmt:…} markers wrapping the commented spanComment text appears in a Comments section at end
w:footnoteReference[^fn-xxxx] GFM footnote refDefinitions collected at end
w:endnoteReference[^en-xxxx]Same
w:drawing / w:pict (image)Nothing — the Markdown oracle emits no image syntax and assigns no anchorImages 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 contentThe 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 omitMirrors 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 ph 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):

ModeRendered 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)

  1. 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.
  2. Plain paragraphs + headings. Emit body paragraphs and styled headings with anchors. Tests against TestFiles/HC* documents.
  3. Inline runs. Bold, italic, code, strike, hyperlinks.
  4. Lists with resolved numbering. Lean on existing ListItemRetrieverSettings infrastructure.
  5. Simple tables (GFM) + opaque fallback.
  6. Multipart parts. Headers, footers, footnotes, endnotes, comments.
  7. Tracked changes rendering modes.
  8. 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:

  1. A paragraph styled Heading1 with text "Indemnification"
  2. A paragraph (Normal style) with text "The Provider shall indemnify..."
  3. 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 # Document header 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 in Docxodus/PtOpenXmlUtil.cs).
  • The existing Unid-assignment logic lives in two places, neither of which is general-purpose:
    • WmlComparer.AssignUnidToAllElements(XElement) — private to WmlComparer.cs:8655. Walks descendants, adds a Unid where missing. Also handles the special w:footnote/w:endnote case where the container itself needs a Unid.
    • WmlToXmlUtil.AssignUnidToBlc — block-level only, on a WmlDocument or WordprocessingDocument.
  • For Phase 1, extract the WmlComparer helper into a shared internal utility (e.g. UnidHelper.AssignToAllBlockElements) and call it from both WmlComparer and the new converter. Don't duplicate.

First deliverable (mergeable on its own):

  1. Extract the Unid helper.
  2. Implement WmlToMarkdownConverter.Convert(...) such that it returns a MarkdownProjection with Markdown = "" and a populated AnchorIndex for every block-level element (w:p, w:tbl, w:tr, w:tc) across body, headers, footers, footnotes, endnotes, and comments.
  3. Implement AnchorTarget.Resolve(WordprocessingDocument) — given the anchor's PartUri and Unid, walk that part's XML to find the matching element.
  4. Tests:
    • MD001_AnchorIndexIsExhaustive — every w:p/w:tbl in 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 through Resolve back to a non-null XElement whose PtOpenXml.Unid attribute matches.
    • MD004_AnchorsSurviveRoundTrip — load → project → save (via OpenXmlMemoryStreamDocument) → 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 Convert entry points.
  • Per-element handler methods (WmlToHtmlConverter.ProcessParagraph at line 4279 is the model — see the dispatch in ConvertToHtmlTransform around 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:

  1. Add [JSExport]-decorated methods to wasm/DocxodusWasm/DocumentConverter.cs that take/return JSON-serializable shapes (you can't return XElement or IReadOnlyDictionary directly — flatten to MarkdownProjectionDto).
  2. Add TypeScript types and a wrapper in npm/src/types.ts + npm/src/index.ts.
  3. Update the DocxodusWasmExports interface.
  4. Build with cd npm && npm run build, then add Playwright tests under npm/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 WordprocessingDocument walk.

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 OpenXmlMemoryStreamDocument round 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:pict emit path, no img/drw anchor kind, and ImageUriBuilder is 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 or imageId is the addressing authority.
  • Bidirectional editing. This converter is one-way, but the eventual MarkdownToWml story (or, more likely, an ApplyEdit(anchor, op) API) needs to be designed alongside its callers — not in isolation here.