diff --git a/md2doc.py b/md2doc.py
index e5e3f88..e6c62db 100644
--- a/md2doc.py
+++ b/md2doc.py
@@ -323,6 +323,48 @@ def _render_paragraph(doc, node, style: dict):
_add_runs(node)
+def _render_list(doc, node, style: dict, level: int = 0, num_state: dict = None):
+ """Render
/ recursively. Uses register_multilevel_numbering + attach_list_numbering.
+
+ `num_state` carries the registered numId across recursion (one numbering definition
+ per top-level list, all children share it).
+
+ Spec §4.2: mandatory manual numbering — List Bullet 2/3 styles don't work.
+ """
+ is_ordered = node.name == "ol"
+ kind = "decimal" if is_ordered else "bullet"
+
+ # Top-level call: register fresh numbering definition
+ if num_state is None:
+ num_id = register_multilevel_numbering(doc, levels=3, kind=kind)
+ num_state = {"num_id": num_id}
+
+ for li in node.find_all("li", recursive=False):
+ # The li's direct text (excluding nested lists)
+ nested_lists = []
+ for child in li.children:
+ if hasattr(child, "name") and child.name in ("ul", "ol"):
+ nested_lists.append(child)
+
+ # Get text content of the li (excluding nested list text)
+ text_parts = []
+ for child in li.children:
+ if hasattr(child, "name") and child.name in ("ul", "ol"):
+ continue
+ if isinstance(child, str):
+ text_parts.append(child.strip())
+ else:
+ text_parts.append(child.get_text(strip=True))
+ text = " ".join(p for p in text_parts if p)
+
+ p = doc.add_paragraph(text)
+ attach_list_numbering(p, num_id=num_state["num_id"], ilvl=level)
+
+ # Recurse into nested lists
+ for nested in nested_lists:
+ _render_list(doc, nested, style=style, level=level + 1, num_state=num_state)
+
+
def convert_md_to_doc(md_file: Path, output_file: Path, style: str = "elegant") -> Path:
"""Convert a single markdown file to DOCX.
diff --git a/tests/test_md2doc_lists.py b/tests/test_md2doc_lists.py
new file mode 100644
index 0000000..72d50d4
--- /dev/null
+++ b/tests/test_md2doc_lists.py
@@ -0,0 +1,130 @@
+"""Tests for list rendering — verifies nested levels use correct ilvl."""
+import zipfile
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from docx import Document
+from bs4 import BeautifulSoup
+
+from md2doc import STYLES, _render_list
+
+W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
+
+
+def get_doc_xml(doc):
+ tmp = Path(__file__).parent / "_tmp_list.docx"
+ doc.save(tmp)
+ try:
+ with zipfile.ZipFile(tmp) as z:
+ return ET.fromstring(z.read("word/document.xml"))
+ finally:
+ tmp.unlink()
+
+
+def test_flat_bullet_list_produces_3_items_at_ilvl_0():
+ html = """
+
+ - Item A
+ - Item B
+ - Item C
+
+ """
+ doc = Document()
+ node = BeautifulSoup(html, "html.parser").find("ul")
+ _render_list(doc, node, style=STYLES["elegant"])
+ root = get_doc_xml(doc)
+ ilvls = [int(el.get(f"{{{W_NS}}}val")) for el in root.iter(f"{{{W_NS}}}ilvl")]
+ assert ilvls == [0, 0, 0], f"expected all ilvl=0, got {ilvls}"
+
+
+def test_nested_bullet_list_uses_increasing_ilvl_and_resolves_to_multilevel_abstractNum():
+ """BEHAVIORAL: paragraphs have ilvl=[0,1,2], AND the numId they reference
+ resolves to an abstractNum that actually defines those levels. A no-op
+ register_multilevel_numbering (return 1) would leave paragraphs pointing to
+ default-template numId=1 (single-level), so the test must FAIL.
+ """
+ html = """
+
+ """
+ doc = Document()
+ node = BeautifulSoup(html, "html.parser").find("ul")
+ _render_list(doc, node, style=STYLES["elegant"])
+
+ tmp = Path(__file__).parent / "_tmp_list_full.docx"
+ doc.save(tmp)
+ try:
+ with zipfile.ZipFile(tmp) as z:
+ doc_xml = z.read("word/document.xml")
+ num_xml = z.read("word/numbering.xml")
+ doc_root = ET.fromstring(doc_xml)
+ num_root = ET.fromstring(num_xml)
+
+ # 1. Paragraphs must have ilvl=[0,1,2]
+ ilvls = [int(el.get(f"{{{W_NS}}}val"))
+ for el in doc_root.iter(f"{{{W_NS}}}ilvl")]
+ assert ilvls == [0, 1, 2], f"expected paragraph ilvl=[0,1,2], got {ilvls}"
+
+ # 2. All paragraphs must reference the SAME numId
+ numIds = [el.get(f"{{{W_NS}}}val")
+ for el in doc_root.iter(f"{{{W_NS}}}numId")]
+ assert len(set(numIds)) == 1, \
+ f"expected all paragraphs to share one numId, got {set(numIds)}"
+ our_num_id = numIds[0]
+
+ # 3. numId resolves to abstractNum that defines ilvl=0,1,2
+ our_num = None
+ for num_el in num_root.findall(f"{{{W_NS}}}num"):
+ if num_el.get(f"{{{W_NS}}}numId") == our_num_id:
+ our_num = num_el
+ break
+ assert our_num is not None, f"our numId={our_num_id} not found in numbering.xml"
+
+ abstract_ref = our_num.find(f"{{{W_NS}}}abstractNumId")
+ assert abstract_ref is not None
+ abstract_id = abstract_ref.get(f"{{{W_NS}}}val")
+
+ our_abstract = None
+ for an in num_root.findall(f"{{{W_NS}}}abstractNum"):
+ if an.get(f"{{{W_NS}}}abstractNumId") == abstract_id:
+ our_abstract = an
+ break
+ assert our_abstract is not None, f"abstractNum {abstract_id} not found"
+
+ abstract_ilvls = sorted(int(l.get(f"{{{W_NS}}}ilvl"))
+ for l in our_abstract.findall(f"{{{W_NS}}}lvl"))
+ assert abstract_ilvls == [0, 1, 2], \
+ f"our abstractNum must define ilvl=[0,1,2], got {abstract_ilvls}"
+ finally:
+ tmp.unlink()
+
+
+def test_numbered_list_uses_decimal_format():
+ html = """
+
+ - First
+ - Second
+
+ """
+ doc = Document()
+ node = BeautifulSoup(html, "html.parser").find("ol")
+ _render_list(doc, node, style=STYLES["elegant"])
+ tmp = Path(__file__).parent / "_tmp_num.docx"
+ doc.save(tmp)
+ try:
+ with zipfile.ZipFile(tmp) as z:
+ num_xml = z.read("word/numbering.xml")
+ num_root = ET.fromstring(num_xml)
+ fmts = [el.get(f"{{{W_NS}}}val") for el in num_root.iter(f"{{{W_NS}}}numFmt")]
+ assert "decimal" in fmts
+ finally:
+ tmp.unlink()