# Story 5.3: DOCX Provider (python-docx)

Status: done

## Story

As a **user**,
I want **to generate an editable DOCX resume**,
So that **I can make final tweaks or submit where Word format is required**.

## Acceptance Criteria

1. **Given** a ResumeData instance
   **When** the DOCXProvider renders it
   **Then** a DOCX file is generated
   **And** the document has proper Word formatting

2. **Given** I run `resume build --format docx`
   **When** the build completes
   **Then** `dist/resume.docx` is created
   **And** the file opens correctly in Microsoft Word and Google Docs

3. **Given** the generated DOCX
   **When** I open it in Word
   **Then** headings use proper Word heading styles
   **And** bullet points are actual Word bullets (not text dashes)
   **And** the document is editable

4. **Given** I run `resume build` without `--format`
   **When** the build completes
   **Then** both PDF and DOCX are generated by default

## Tasks / Subtasks

- [x] Task 1: Create DOCXProvider class (AC: #1)
  - [x] 1.1: Create `src/resume_as_code/providers/docx.py`
  - [x] 1.2: Implement `DOCXProvider` class with `render()` method
  - [x] 1.3: Use python-docx for document generation
  - [x] 1.4: Return path to generated file

- [x] Task 2: Implement document structure (AC: #3)
  - [x] 2.1: Create document with proper page setup
  - [x] 2.2: Add contact header with name and info
  - [x] 2.3: Add summary paragraph
  - [x] 2.4: Add experience sections with headings
  - [x] 2.5: Add skills section

- [x] Task 3: Apply Word styles (AC: #3)
  - [x] 3.1: Use Heading 1 for name (bold 24pt centered)
  - [x] 3.2: Use Heading 2 for section titles
  - [x] 3.3: Use bold for job titles (inline with org/dates)
  - [x] 3.4: Use proper bullet list style (List Bullet)
  - [x] 3.5: Set fonts and sizes

- [x] Task 4: Handle bullets properly (AC: #3)
  - [x] 4.1: Use actual Word bullet lists (List Bullet style)
  - [x] 4.2: Set proper indentation (0.25" for scope indicators)
  - [x] 4.3: Ensure consistent spacing (Pt(3) after bullets)

- [x] Task 5: Code quality verification
  - [x] 5.1: Run `ruff check src tests --fix`
  - [x] 5.2: Run `mypy src --strict` with zero errors
  - [x] 5.3: Add tests for DOCX generation (16 tests)
  - [x] 5.4: Test opening in Word/Google Docs (validated via python-docx Document() load)

## Dev Notes

### Architecture Compliance

This story implements the DOCX provider using python-docx per Architecture Section 2.4.

**Source:** [epics.md#Story 5.3](_bmad-output/planning-artifacts/epics.md)

### Dependencies

This story REQUIRES:
- Story 5.1 (Resume Data Model & Template System)

This story ENABLES:
- Story 5.4 (Build Command)

### DOCXProvider Implementation

**`src/resume_as_code/providers/docx.py`:**

```python
"""DOCX provider using python-docx."""

from __future__ import annotations

from pathlib import Path

from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.shared import Inches, Pt

from resume_as_code.models.resume import ResumeData


class DOCXProvider:
    """Provider for generating DOCX resumes."""

    def __init__(self) -> None:
        """Initialize DOCX provider."""
        pass

    def render(self, resume: ResumeData, output_path: Path) -> Path:
        """Render resume to DOCX.

        Args:
            resume: ResumeData to render.
            output_path: Path for output DOCX file.

        Returns:
            Path to generated DOCX.
        """
        doc = Document()

        # Set up page margins
        sections = doc.sections
        for section in sections:
            section.top_margin = Inches(0.75)
            section.bottom_margin = Inches(0.75)
            section.left_margin = Inches(0.75)
            section.right_margin = Inches(0.75)

        # Header with name
        self._add_header(doc, resume)

        # Summary
        if resume.summary:
            self._add_section_heading(doc, "Summary")
            p = doc.add_paragraph(resume.summary)
            p.paragraph_format.space_after = Pt(12)

        # Experience sections
        for section in resume.sections:
            self._add_section_heading(doc, section.title)

            for item in section.items:
                self._add_experience_item(doc, item)

        # Skills
        if resume.skills:
            self._add_section_heading(doc, "Skills")
            p = doc.add_paragraph(", ".join(resume.skills))

        # Ensure output directory exists
        output_path.parent.mkdir(parents=True, exist_ok=True)

        # Save document
        doc.save(output_path)

        return output_path

    def _add_header(self, doc: Document, resume: ResumeData) -> None:
        """Add header with contact info."""
        # Name
        name_para = doc.add_paragraph()
        name_run = name_para.add_run(resume.contact.name)
        name_run.bold = True
        name_run.font.size = Pt(24)
        name_para.alignment = WD_ALIGN_PARAGRAPH.CENTER

        # Contact line
        contact_parts = []
        if resume.contact.email:
            contact_parts.append(resume.contact.email)
        if resume.contact.phone:
            contact_parts.append(resume.contact.phone)
        if resume.contact.location:
            contact_parts.append(resume.contact.location)

        if contact_parts:
            contact_para = doc.add_paragraph(" | ".join(contact_parts))
            contact_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
            contact_para.paragraph_format.space_after = Pt(12)

        # Links line
        link_parts = []
        if resume.contact.linkedin:
            link_parts.append(resume.contact.linkedin)
        if resume.contact.github:
            link_parts.append(resume.contact.github)

        if link_parts:
            links_para = doc.add_paragraph(" | ".join(link_parts))
            links_para.alignment = WD_ALIGN_PARAGRAPH.CENTER

    def _add_section_heading(self, doc: Document, title: str) -> None:
        """Add a section heading."""
        heading = doc.add_heading(title, level=2)
        heading.paragraph_format.space_before = Pt(12)
        heading.paragraph_format.space_after = Pt(6)

    def _add_experience_item(self, doc: Document, item) -> None:
        """Add an experience item with bullets."""
        # Title and dates on same line
        title_para = doc.add_paragraph()
        title_run = title_para.add_run(item.title)
        title_run.bold = True

        if item.organization:
            title_para.add_run(f" | {item.organization}")

        if item.start_date:
            dates = f"{item.start_date} - {item.end_date or 'Present'}"
            title_para.add_run(f"  ({dates})")

        # Scope indicators for executive roles
        if item.scope_team_size or item.scope_budget:
            scope_parts = []
            if item.scope_team_size:
                scope_parts.append(f"Team: {item.scope_team_size}")
            if item.scope_budget:
                scope_parts.append(f"Budget: {item.scope_budget}")
            scope_para = doc.add_paragraph(", ".join(scope_parts))
            scope_para.paragraph_format.left_indent = Inches(0.25)
            for run in scope_para.runs:
                run.italic = True

        # Bullets
        for bullet in item.bullets:
            bullet_para = doc.add_paragraph(bullet.text, style="List Bullet")
            bullet_para.paragraph_format.space_after = Pt(3)

        # Add space after item
        doc.add_paragraph()
```

### Testing Requirements

**`tests/unit/test_docx_provider.py`:**

```python
"""Tests for DOCX provider."""

from pathlib import Path

import pytest
from docx import Document

from resume_as_code.models.resume import (
    ResumeData,
    ContactInfo,
    ResumeSection,
    ResumeItem,
    ResumeBullet,
)
from resume_as_code.providers.docx import DOCXProvider


@pytest.fixture
def sample_resume() -> ResumeData:
    """Create sample resume for testing."""
    return ResumeData(
        contact=ContactInfo(
            name="John Doe",
            email="john@example.com",
            phone="555-1234",
            location="San Francisco, CA",
        ),
        summary="Experienced software engineer with 10+ years in Python.",
        sections=[
            ResumeSection(
                title="Experience",
                items=[
                    ResumeItem(
                        title="Senior Engineer",
                        organization="Tech Corp",
                        start_date="2020-01",
                        end_date="Present",
                        bullets=[
                            ResumeBullet(text="Built scalable APIs"),
                            ResumeBullet(text="Led team of 5 engineers"),
                        ],
                    ),
                ],
            ),
        ],
        skills=["Python", "AWS", "Kubernetes"],
    )


class TestDOCXProvider:
    """Tests for DOCXProvider."""

    def test_generates_docx(self, sample_resume: ResumeData, tmp_path: Path):
        """Should generate a DOCX file."""
        output_path = tmp_path / "resume.docx"
        provider = DOCXProvider()

        result = provider.render(sample_resume, output_path)

        assert result.exists()
        assert result.suffix == ".docx"

    def test_docx_is_valid(self, sample_resume: ResumeData, tmp_path: Path):
        """Generated DOCX should be openable."""
        output_path = tmp_path / "resume.docx"
        provider = DOCXProvider()
        provider.render(sample_resume, output_path)

        # Should not raise
        doc = Document(output_path)
        assert len(doc.paragraphs) > 0

    def test_contains_name(self, sample_resume: ResumeData, tmp_path: Path):
        """DOCX should contain the name."""
        output_path = tmp_path / "resume.docx"
        provider = DOCXProvider()
        provider.render(sample_resume, output_path)

        doc = Document(output_path)
        text = "\n".join(p.text for p in doc.paragraphs)

        assert "John Doe" in text

    def test_contains_bullets(self, sample_resume: ResumeData, tmp_path: Path):
        """DOCX should contain bullet points."""
        output_path = tmp_path / "resume.docx"
        provider = DOCXProvider()
        provider.render(sample_resume, output_path)

        doc = Document(output_path)
        text = "\n".join(p.text for p in doc.paragraphs)

        assert "Built scalable APIs" in text
```

### Verification Commands

```bash
# Test DOCX generation
python -c "
from pathlib import Path
from resume_as_code.models.resume import (
    ResumeData, ContactInfo, ResumeSection, ResumeItem, ResumeBullet
)
from resume_as_code.providers.docx import DOCXProvider

resume = ResumeData(
    contact=ContactInfo(name='John Doe', email='john@example.com'),
    summary='Experienced engineer',
    sections=[
        ResumeSection(
            title='Experience',
            items=[
                ResumeItem(
                    title='Senior Engineer',
                    organization='Tech Corp',
                    bullets=[ResumeBullet(text='Built APIs')],
                ),
            ],
        ),
    ],
    skills=['Python', 'AWS'],
)

provider = DOCXProvider()
output = provider.render(resume, Path('test-resume.docx'))
print(f'Generated: {output}')
"

# Open in Word/Preview
open test-resume.docx

# Clean up
rm test-resume.docx
```

### References

- [Source: epics.md#Story 5.3](_bmad-output/planning-artifacts/epics.md)

## Dev Agent Record

### Agent Model Used

Claude Opus 4.5 (claude-opus-4-5-20251101)

### Debug Log References

- Initial mypy failures due to python-docx lacking type stubs - resolved by using `Any` type for Document
- Renamed loop variables (`section` → `doc_section`, `resume_section`) to avoid shadowing

### Completion Notes List

- ✅ Created DOCXProvider class with `render()` method at `src/resume_as_code/providers/docx.py`
- ✅ Implemented proper page margins (0.75" all sides)
- ✅ Added centered header with name (bold, 24pt), contact info, and links
- ✅ Added Summary section with proper spacing
- ✅ Added Experience sections using Heading 2 style
- ✅ Implemented proper Word "List Bullet" style for achievement bullets
- ✅ Added executive scope indicators (Team size, Budget) with italic styling
- ✅ Added Skills section as comma-separated list
- ✅ Added Education section rendering
- ✅ Added `render_to_bytes()` method for streaming/in-memory processing
- ✅ Added error handling wrapping exceptions in RenderError
- ✅ Exported DOCXProvider from providers/__init__.py
- ✅ Optimized spacer paragraphs to avoid trailing whitespace
- ✅ All 29 unit tests pass covering: rendering, content, styles, scope fields, education, render_to_bytes, error handling, performance, package export
- ✅ All 871 project tests pass (no regressions)
- ✅ ruff check passes (no lint errors)
- ✅ mypy --strict passes (zero type errors)

### File List

- `src/resume_as_code/providers/docx.py` (NEW) - DOCXProvider implementation
- `src/resume_as_code/providers/__init__.py` (MODIFIED) - Added DOCXProvider export
- `tests/unit/test_docx_provider.py` (NEW) - 29 unit tests for DOCXProvider

### Change Log

- 2026-01-11: Implemented Story 5.3 - DOCX Provider using python-docx
- 2026-01-11: Code review fixes - Added education rendering, render_to_bytes(), error handling, package export, performance tests

